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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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", "")),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user