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]:
+5 -3
View File
@@ -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:
+53 -20
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from pydantic import BaseModel, Field, field_validator
from fluksio.core.config import settings
from fluksio.flow.dashboards import DashboardNotFound, DashboardStore
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.schemas import _validate_name
@@ -101,29 +101,62 @@ def find(panel_id: str) -> PanelDef | None:
return None
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message this panel's dashboards read or write.
def dashboards_for(panel_id: str, store: DashboardStore) -> list[DashboardDef]:
"""The published documents this panel shows, in rail order.
What a screen is entitled to see, as its own dashboards define it. Read
from the published documents, since that is what a panel draws, and empty
for a panel that is gone — which is the same answer as "nothing".
Published, since that is what a panel draws. A name that no longer
resolves is a dashboard someone deleted and is skipped, and a panel that
is gone shows nothing.
Handed back whole rather than walked here, because what a panel may do
with a message depends on the document it came from — a dashboard that
says it is locked entitles a screen to read it and not to touch it.
"""
panel = find(panel_id)
if panel is None:
return []
found: list[DashboardDef] = []
for name in panel.dashboards:
try:
found.append(store.read(name))
except DashboardNotFound:
continue
return found
def messages_of(defn: DashboardDef) -> set[str]:
"""Every message one dashboard reads or writes.
A dashboard's own bound settings count, not only its widgets': the theme a
panel is driven to is a message no tile on it draws, and a wall panel
refused its own theme message is the one surface the setting exists for.
"""
panel = find(panel_id)
if panel is None:
return set()
names: set[str] = set()
for dashboard in panel.dashboards:
try:
defn = store.read(dashboard)
except DashboardNotFound:
continue
names.update(defn.setting_messages)
for widget in defn.widgets:
names.update(widget.messages)
if widget.target:
names.add(widget.target)
names = set(defn.setting_messages)
for widget in defn.widgets:
names.update(widget.messages)
if widget.target:
names.add(widget.target)
return names
def requests_of(defn: DashboardDef) -> set[str]:
"""The publishes this dashboard makes in order to read.
A querying chart asks a flow for the series it draws by publishing a
request, so that publish is how the tile reads rather than something
anyone touched. Every other message a dashboard sends comes from a
control, which is what marking it read-only turns off — so this is what a
locked dashboard is still entitled to send.
"""
return {w.target for w in defn.widgets if w.type == "chart" and w.target}
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message this panel's dashboards read or write.
What a screen is entitled to see, as its own dashboards define it, and
empty for a panel that is gone — which is the same answer as "nothing".
"""
return {
name for defn in dashboards_for(panel_id, store) for name in messages_of(defn)
}
+19 -2
View File
@@ -13,12 +13,14 @@ from urllib.parse import urlparse
import httpx
import jwt
from fastapi.concurrency import run_in_threadpool
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from fluksio.api.deps import oauth_client_lives
from fluksio.core import security
from fluksio.core.config import settings
from fluksio.mcp import server
@@ -30,11 +32,18 @@ _INTERNAL_BASE = "http://fluksio-mcp.internal"
class _JWTVerifier:
"""Accept only tokens minted for the MCP channel.
"""Accept only tokens minted for the MCP channel, by agents still registered.
A perfectly valid browser token is refused: it was issued for a person's
session, and honouring it here would make agent traffic indistinguishable
from theirs.
The client row is looked up because the token itself cannot be withdrawn:
it is stateless and good until ``MCP_TOKEN_EXPIRE_MINUTES`` runs out, so
the registration it names is the thing revoking an agent actually removes.
Refusing at the door rather than leaving it to the API the tools call means
a revoked agent gets the 401 that sends it back to authorize, instead of a
tool listing that works and a tool call that does not.
"""
async def verify_token(self, token: str) -> AccessToken | None:
@@ -46,9 +55,17 @@ class _JWTVerifier:
if payload.get("mcp") is not True:
logger.debug("MCP token rejected: not an MCP-channel token")
return None
client_id = str(payload.get("client_id", ""))
# Off the event loop: the lookup is SQLite, like every other read this
# process makes, and the session it opens is blocking.
if not await run_in_threadpool(oauth_client_lives, client_id):
logger.debug(
"MCP token rejected: agent %s is no longer registered", client_id
)
return None
return AccessToken(
token=token,
client_id=str(payload.get("client_id", "")),
client_id=client_id,
scopes=[security.MCP_SCOPE],
subject=str(payload.get("sub", "")),
)