Make revoking an agent and locking a dashboard actually revoke and lock
An MCP access token is a stateless JWT good until it expires, so deleting the client row revoked nothing already handed out — on the MCP endpoint or on the REST API, which takes the same token directly. Both doors now look the client up by the `client_id` the token has always carried, so tokens already in circulation are held to it too. A dashboard's `locked` setting stopped the client drawing a control and nothing else; the server took a publish from a panel showing it anyway. It now bounds the panel's write scope, resolved live where a flow drives the flag, exactly as the client resolves it. Reads are untouched — read-only is not blind — and so is a querying chart's request, which is how that tile reads rather than something anyone touched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
This commit is contained in:
@@ -18,9 +18,9 @@ from fluksio.core.db import engine
|
||||
from fluksio.flow import panels
|
||||
from fluksio.flow.artifacts import is_reference
|
||||
from fluksio.flow.controller import FlowController
|
||||
from fluksio.flow.dashboards import DashboardStore
|
||||
from fluksio.flow.dashboards import DashboardDef, DashboardStore
|
||||
from fluksio.flow.workers import PythonWorkerPool
|
||||
from fluksio.models import TokenPayload, User
|
||||
from fluksio.models import OAuthClient, TokenPayload, User
|
||||
|
||||
reusable_oauth2 = OAuth2PasswordBearer(
|
||||
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
||||
@@ -80,6 +80,60 @@ def _panel_messages(panel_id: str, request: Request) -> set[str]:
|
||||
return panels.messages_for(panel_id, store)
|
||||
|
||||
|
||||
def _locked(defn: DashboardDef, live: dict[str, Any]) -> bool:
|
||||
"""Whether this dashboard is read-only right now.
|
||||
|
||||
Read the way the client reads it (``Dashboard/settings.tsx``): the bound
|
||||
message if it is carrying something, and the stored value otherwise. Live,
|
||||
because a lock a flow drives is the case the binding exists for — judging
|
||||
it from the stored fallback alone would refuse every control on a
|
||||
dashboard its flow has unlocked.
|
||||
"""
|
||||
setting = defn.settings.get("locked")
|
||||
if setting is None:
|
||||
return False
|
||||
value = live.get(setting.message) if setting.message else None
|
||||
return (setting.value if value is None else value) is True
|
||||
|
||||
|
||||
def _panel_writable(panel_id: str, request: Request) -> set[str]:
|
||||
"""The messages this panel may publish to.
|
||||
|
||||
The same walk as the read allowlist, minus whatever a locked dashboard
|
||||
contributes: ``locked`` is a dashboard saying it is there to be looked at,
|
||||
and until this it stopped only the client drawing the control — the server
|
||||
took the publish from a screen that asked anyway.
|
||||
|
||||
Not quite all of it: a querying chart publishes the request it reads by,
|
||||
and a locked dashboard whose charts cannot ask goes blank rather than
|
||||
read-only. That request is the exception ``panels.requests_of`` names.
|
||||
|
||||
Union, exactly as the allowlist itself is: a message one dashboard on this
|
||||
panel displays and another controls stays writable, because the unlocked
|
||||
one is what entitles the screen to it. Reads are untouched — read-only is
|
||||
not blind, and a locked dashboard has to keep drawing live data.
|
||||
"""
|
||||
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
|
||||
if store is None:
|
||||
return set()
|
||||
defns = panels.dashboards_for(panel_id, store)
|
||||
bound = sorted(
|
||||
{s.message for d in defns if (s := d.settings.get("locked")) and s.message}
|
||||
)
|
||||
controller: FlowController | None = getattr(
|
||||
request.app.state, "flow_controller", None
|
||||
)
|
||||
live = controller.state.get_present(bound) if bound and controller else {}
|
||||
writable: set[str] = set()
|
||||
for defn in defns:
|
||||
writable |= (
|
||||
panels.requests_of(defn)
|
||||
if _locked(defn, live)
|
||||
else panels.messages_of(defn)
|
||||
)
|
||||
return writable
|
||||
|
||||
|
||||
def _panel_digests(panel_id: str, request: Request) -> set[str]:
|
||||
"""The artifacts this panel's messages are pointing at right now.
|
||||
|
||||
@@ -116,7 +170,10 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
|
||||
asks for its window by publishing a request, which is why that request
|
||||
counts as one of its widget's messages. Bounded to those, though: a screen
|
||||
on a wall has no business reaching a message no tile on it draws, and the
|
||||
catalogue behind ``GET /messages/`` is the whole namespace at once.
|
||||
catalogue behind ``GET /messages/`` is the whole namespace at once. A
|
||||
dashboard that says it is locked is bounded further still — it entitles a
|
||||
panel to read every message it names and to publish to none of them but
|
||||
the requests its own charts ask by.
|
||||
|
||||
# ponytail: the panel file and the published dashboards are re-read per
|
||||
# request. Cache them behind the store's version if this shows up in a
|
||||
@@ -146,13 +203,16 @@ def _panel_may(payload: dict[str, Any], request: Request) -> None:
|
||||
# value in. Percent-decoded, because a message name is a path segment
|
||||
# here and the client encodes it as one.
|
||||
rest = unquote(path[len(f"{api}/messages/") :])
|
||||
scope = _panel_messages
|
||||
if method == "GET" and rest.endswith(_HISTORY):
|
||||
name = rest[: -len(_HISTORY)]
|
||||
elif method == "POST":
|
||||
name = rest
|
||||
# A locked dashboard entitles the panel to nothing it can publish.
|
||||
scope = _panel_writable
|
||||
else:
|
||||
name = ""
|
||||
allowed = bool(name) and name in _panel_messages(panel.id, request)
|
||||
allowed = bool(name) and name in scope(panel.id, request)
|
||||
elif method == "GET" and path.startswith(f"{api}/artifacts/"):
|
||||
# The bytes behind a media message a tile on this panel is drawing.
|
||||
# Scoped to what those messages hold *now*, which is exactly what a
|
||||
@@ -182,6 +242,10 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
refuses it outright, so the only door it fits is the one ``_panel_may``
|
||||
guards.
|
||||
|
||||
An agent's token is held to one thing more: the client it names has to
|
||||
still be registered, which is what makes revoking an agent take effect now
|
||||
rather than whenever its stateless token happens to expire.
|
||||
|
||||
The last branch is the seam a hosted deployment widens: a portal this
|
||||
instance was enrolled with signs tokens with a key pinned at
|
||||
enrolment, and they name the portal account holding them, which resolves
|
||||
@@ -209,9 +273,38 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
raise InvalidTokenError("a panel token must name its panel")
|
||||
return _gate_panel(panel_token, request)
|
||||
try:
|
||||
return security.decode_oauth_token(token)
|
||||
agent = security.decode_oauth_token(token)
|
||||
except InvalidTokenError:
|
||||
return _gate_panel(cloud_config.decode_portal_token(token), request)
|
||||
return _gate_agent(agent)
|
||||
|
||||
|
||||
def oauth_client_lives(client_id: str) -> bool:
|
||||
"""Is the agent a token names still a registered client? Blocking.
|
||||
|
||||
An MCP access token is a stateless JWT good until it expires, so deleting
|
||||
the client row revoked nothing that had already been handed out. The
|
||||
client id is a claim the token has always carried, so this holds the ones
|
||||
already in circulation just as well as the next one minted.
|
||||
|
||||
One primary-key read per agent request, against a table with a row per
|
||||
registered agent. The request it gates goes on to look its user up the
|
||||
same way, and the panel gate next door re-reads the panels file and every
|
||||
published dashboard from disk, so this is the cheapest check in here.
|
||||
"""
|
||||
try:
|
||||
key = uuid.UUID(client_id)
|
||||
except ValueError:
|
||||
return False
|
||||
with Session(engine) as session:
|
||||
return session.get(OAuthClient, key) is not None
|
||||
|
||||
|
||||
def _gate_agent(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Refuse a token whose agent has been revoked."""
|
||||
if not oauth_client_lives(str(payload.get("client_id") or "")):
|
||||
raise InvalidTokenError("this agent's registration was withdrawn")
|
||||
return payload
|
||||
|
||||
|
||||
def _gate_panel(payload: dict[str, Any], request: Request | None) -> dict[str, Any]:
|
||||
|
||||
@@ -555,9 +555,11 @@ def revoke_client(client_id: uuid.UUID, session: SessionDep) -> Any:
|
||||
"""Withdraw one agent's access, leaving every other agent alone.
|
||||
|
||||
Deleting the client cascades to its codes and refresh tokens, so it can
|
||||
get nothing new and cannot come back without registering again. An access
|
||||
token already in its hands keeps working until it expires
|
||||
(``MCP_TOKEN_EXPIRE_MINUTES``) — those are stateless by design.
|
||||
get nothing new and cannot come back without registering again. The access
|
||||
token already in its hands stops working too, even though it is a
|
||||
stateless JWT nobody can reach into: every door that takes one looks the
|
||||
client up first (``api.deps.oauth_client_lives``), so the row deleted here
|
||||
is the whole of the revocation.
|
||||
"""
|
||||
client = session.get(OAuthClient, client_id)
|
||||
if client is None:
|
||||
|
||||
Reference in New Issue
Block a user