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:
2026-09-06 15:24:53 +02:00
co-authored by Claude Opus 5
parent 15c3dd5838
commit 8cb843eb25
8 changed files with 331 additions and 43 deletions
+98 -5
View File
@@ -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]: