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 import panels
|
||||||
from fluksio.flow.artifacts import is_reference
|
from fluksio.flow.artifacts import is_reference
|
||||||
from fluksio.flow.controller import FlowController
|
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.flow.workers import PythonWorkerPool
|
||||||
from fluksio.models import TokenPayload, User
|
from fluksio.models import OAuthClient, TokenPayload, User
|
||||||
|
|
||||||
reusable_oauth2 = OAuth2PasswordBearer(
|
reusable_oauth2 = OAuth2PasswordBearer(
|
||||||
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
|
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)
|
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]:
|
def _panel_digests(panel_id: str, request: Request) -> set[str]:
|
||||||
"""The artifacts this panel's messages are pointing at right now.
|
"""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
|
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
|
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
|
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
|
# 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
|
# 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
|
# value in. Percent-decoded, because a message name is a path segment
|
||||||
# here and the client encodes it as one.
|
# here and the client encodes it as one.
|
||||||
rest = unquote(path[len(f"{api}/messages/") :])
|
rest = unquote(path[len(f"{api}/messages/") :])
|
||||||
|
scope = _panel_messages
|
||||||
if method == "GET" and rest.endswith(_HISTORY):
|
if method == "GET" and rest.endswith(_HISTORY):
|
||||||
name = rest[: -len(_HISTORY)]
|
name = rest[: -len(_HISTORY)]
|
||||||
elif method == "POST":
|
elif method == "POST":
|
||||||
name = rest
|
name = rest
|
||||||
|
# A locked dashboard entitles the panel to nothing it can publish.
|
||||||
|
scope = _panel_writable
|
||||||
else:
|
else:
|
||||||
name = ""
|
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/"):
|
elif method == "GET" and path.startswith(f"{api}/artifacts/"):
|
||||||
# The bytes behind a media message a tile on this panel is drawing.
|
# 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
|
# 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``
|
refuses it outright, so the only door it fits is the one ``_panel_may``
|
||||||
guards.
|
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
|
The last branch is the seam a hosted deployment widens: a portal this
|
||||||
instance was enrolled with signs tokens with a key pinned at
|
instance was enrolled with signs tokens with a key pinned at
|
||||||
enrolment, and they name the portal account holding them, which resolves
|
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")
|
raise InvalidTokenError("a panel token must name its panel")
|
||||||
return _gate_panel(panel_token, request)
|
return _gate_panel(panel_token, request)
|
||||||
try:
|
try:
|
||||||
return security.decode_oauth_token(token)
|
agent = security.decode_oauth_token(token)
|
||||||
except InvalidTokenError:
|
except InvalidTokenError:
|
||||||
return _gate_panel(cloud_config.decode_portal_token(token), request)
|
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]:
|
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.
|
"""Withdraw one agent's access, leaving every other agent alone.
|
||||||
|
|
||||||
Deleting the client cascades to its codes and refresh tokens, so it can
|
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
|
get nothing new and cannot come back without registering again. The access
|
||||||
token already in its hands keeps working until it expires
|
token already in its hands stops working too, even though it is a
|
||||||
(``MCP_TOKEN_EXPIRE_MINUTES``) — those are stateless by design.
|
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)
|
client = session.get(OAuthClient, client_id)
|
||||||
if client is None:
|
if client is None:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from pathlib import Path
|
|||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from fluksio.core.config import settings
|
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
|
from fluksio.flow.schemas import _validate_name
|
||||||
|
|
||||||
|
|
||||||
@@ -101,29 +101,62 @@ def find(panel_id: str) -> PanelDef | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
|
def dashboards_for(panel_id: str, store: DashboardStore) -> list[DashboardDef]:
|
||||||
"""Every message this panel's dashboards read or write.
|
"""The published documents this panel shows, in rail order.
|
||||||
|
|
||||||
What a screen is entitled to see, as its own dashboards define it. Read
|
Published, since that is what a panel draws. A name that no longer
|
||||||
from the published documents, since that is what a panel draws, and empty
|
resolves is a dashboard someone deleted and is skipped, and a panel that
|
||||||
for a panel that is gone — which is the same answer as "nothing".
|
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
|
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
|
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.
|
refused its own theme message is the one surface the setting exists for.
|
||||||
"""
|
"""
|
||||||
panel = find(panel_id)
|
names = set(defn.setting_messages)
|
||||||
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:
|
for widget in defn.widgets:
|
||||||
names.update(widget.messages)
|
names.update(widget.messages)
|
||||||
if widget.target:
|
if widget.target:
|
||||||
names.add(widget.target)
|
names.add(widget.target)
|
||||||
return names
|
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 httpx
|
||||||
import jwt
|
import jwt
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
|
from fluksio.api.deps import oauth_client_lives
|
||||||
from fluksio.core import security
|
from fluksio.core import security
|
||||||
from fluksio.core.config import settings
|
from fluksio.core.config import settings
|
||||||
from fluksio.mcp import server
|
from fluksio.mcp import server
|
||||||
@@ -30,11 +32,18 @@ _INTERNAL_BASE = "http://fluksio-mcp.internal"
|
|||||||
|
|
||||||
|
|
||||||
class _JWTVerifier:
|
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
|
A perfectly valid browser token is refused: it was issued for a person's
|
||||||
session, and honouring it here would make agent traffic indistinguishable
|
session, and honouring it here would make agent traffic indistinguishable
|
||||||
from theirs.
|
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:
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
@@ -46,9 +55,17 @@ class _JWTVerifier:
|
|||||||
if payload.get("mcp") is not True:
|
if payload.get("mcp") is not True:
|
||||||
logger.debug("MCP token rejected: not an MCP-channel token")
|
logger.debug("MCP token rejected: not an MCP-channel token")
|
||||||
return None
|
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(
|
return AccessToken(
|
||||||
token=token,
|
token=token,
|
||||||
client_id=str(payload.get("client_id", "")),
|
client_id=client_id,
|
||||||
scopes=[security.MCP_SCOPE],
|
scopes=[security.MCP_SCOPE],
|
||||||
subject=str(payload.get("sub", "")),
|
subject=str(payload.get("sub", "")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -270,6 +270,17 @@ def test_one_agent_can_be_revoked_without_touching_the_others(
|
|||||||
assert revoked.status_code == 200, revoked.text
|
assert revoked.status_code == 200, revoked.text
|
||||||
assert all(c["id"] != client_id for c in listed())
|
assert all(c["id"] != client_id for c in listed())
|
||||||
|
|
||||||
|
# And the access token already in its hands stops working, rather than
|
||||||
|
# outliving the revocation by up to MCP_TOKEN_EXPIRE_MINUTES: the token is
|
||||||
|
# stateless, so the client row it names is what withdrawing it removes.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"{settings.API_V1_STR}/login/test-token",
|
||||||
|
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
|
||||||
# Its refresh token went with it, so it cannot mint itself a new one.
|
# Its refresh token went with it, so it cannot mint itself a new one.
|
||||||
refreshed = client.post(
|
refreshed = client.post(
|
||||||
f"{PREFIX}/token",
|
f"{PREFIX}/token",
|
||||||
|
|||||||
@@ -345,6 +345,88 @@ def test_a_panel_speaks_only_its_own_widgets_messages(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _publish_settings(
|
||||||
|
client: TestClient, headers: dict[str, str], name: str, settings_: dict
|
||||||
|
) -> None:
|
||||||
|
"""Give a published dashboard these settings and publish it again."""
|
||||||
|
saved = client.get(f"{DASHBOARDS}/{name}?draft=true", headers=headers).json()
|
||||||
|
saved["settings"] = settings_
|
||||||
|
written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved)
|
||||||
|
assert written.status_code == 200, written.text
|
||||||
|
published = client.post(
|
||||||
|
f"{DASHBOARDS}/{name}/publish",
|
||||||
|
headers=headers,
|
||||||
|
json={"version": written.json()["version"]},
|
||||||
|
)
|
||||||
|
assert published.status_code == 200, published.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_locked_dashboard_entitles_a_panel_to_read_and_not_to_publish(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
"""`locked` bounds what a screen may send, not what it may draw.
|
||||||
|
|
||||||
|
It used to bound neither: the client stopped offering the control and the
|
||||||
|
server took the publish from anything that asked anyway.
|
||||||
|
|
||||||
|
Unions like the allowlist itself, which is what the second dashboard here
|
||||||
|
is for — a message a locked dashboard shows and an unlocked one controls
|
||||||
|
stays writable, because the unlocked one is what entitles the panel to it.
|
||||||
|
"""
|
||||||
|
_dashboard_with(client, superuser_token_headers, "panel_locked", PANEL_WIDGETS)
|
||||||
|
_publish_settings(
|
||||||
|
client, superuser_token_headers, "panel_locked", {"locked": {"value": True}}
|
||||||
|
)
|
||||||
|
_dashboard_with(
|
||||||
|
client,
|
||||||
|
superuser_token_headers,
|
||||||
|
"panel_open",
|
||||||
|
[{"id": "w_button", "type": "button", "config": {"target": "demo.button"}}],
|
||||||
|
)
|
||||||
|
_panels(
|
||||||
|
client,
|
||||||
|
superuser_token_headers,
|
||||||
|
{"panels": [{"id": "foyer", "dashboards": ["panel_locked", "panel_open"]}]},
|
||||||
|
)
|
||||||
|
panel_headers = _pair(client, superuser_token_headers, "foyer")
|
||||||
|
messages = f"{settings.API_V1_STR}/messages"
|
||||||
|
|
||||||
|
# Only the locked dashboard names it, so there is nothing left to send it.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"{messages}/demo.slider", headers=panel_headers, json={"value": 1}
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
# 404, not 403: past the gate, and refused by an engine that knows no such
|
||||||
|
# message. The unlocked dashboard binds this one too.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"{messages}/demo.button", headers=panel_headers, json={"value": 1}
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
# A querying chart's request survives the lock: publishing it is how that
|
||||||
|
# tile *reads*, and a locked dashboard whose charts cannot ask goes blank
|
||||||
|
# rather than read-only.
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
f"{messages}/demo.query_request",
|
||||||
|
headers=panel_headers,
|
||||||
|
json={"value": {"range_s": 3600, "interval_s": 60}},
|
||||||
|
).status_code
|
||||||
|
== 404
|
||||||
|
)
|
||||||
|
# And what the locked dashboard draws is still readable — read-only is not
|
||||||
|
# blind, and a wall panel showing stale numbers is the failure to avoid.
|
||||||
|
assert (
|
||||||
|
client.get(
|
||||||
|
f"{messages}/demo.temperature/history", headers=panel_headers
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_a_panel_may_read_its_dashboards_own_settings(
|
def test_a_panel_may_read_its_dashboards_own_settings(
|
||||||
client: TestClient, superuser_token_headers: dict[str, str]
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -854,8 +936,9 @@ def test_a_socket_pushes_only_the_frames_it_was_asked_for(tmp_path) -> None:
|
|||||||
|
|
||||||
store = ArtifactStore(tmp_path / "artifacts")
|
store = ArtifactStore(tmp_path / "artifacts")
|
||||||
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
|
store.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
|
||||||
frame = store.put([b"\x89PNG..."], name="f.png", media_type="image/png",
|
frame = store.put(
|
||||||
volatile=True)
|
[b"\x89PNG..."], name="f.png", media_type="image/png", volatile=True
|
||||||
|
)
|
||||||
kept = store.put([b"checkpoint"], name="w.pt")
|
kept = store.put([b"checkpoint"], name="w.pt")
|
||||||
|
|
||||||
asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]})
|
asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]})
|
||||||
|
|||||||
@@ -2,25 +2,38 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable, Generator
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
from fluksio.core import security
|
from fluksio.core import security
|
||||||
from fluksio.core.config import settings
|
from fluksio.core.config import settings
|
||||||
from fluksio.main import app
|
from fluksio.main import app
|
||||||
|
from fluksio.models import OAuthClient
|
||||||
|
|
||||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
|
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
|
||||||
|
|
||||||
|
|
||||||
def mcp_token(user_id: uuid.UUID) -> str:
|
def mcp_token(user_id: uuid.UUID, client_id: uuid.UUID) -> str:
|
||||||
return security.create_oauth_access_token(
|
return security.create_oauth_access_token(user_id, client_id, timedelta(minutes=5))
|
||||||
user_id, uuid.uuid4(), timedelta(minutes=5)
|
|
||||||
)
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agent(db: Session) -> Generator[uuid.UUID, None, None]:
|
||||||
|
"""A registered agent, because a token naming one that is gone is refused."""
|
||||||
|
row = OAuthClient(client_name="Test agent", redirect_uris=[])
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
yield row.id
|
||||||
|
left = db.get(OAuthClient, row.id)
|
||||||
|
if left is not None:
|
||||||
|
db.delete(left)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -96,12 +109,15 @@ def test_a_browser_token_is_not_an_agent_token(
|
|||||||
|
|
||||||
|
|
||||||
def test_an_agent_can_list_and_call_tools(
|
def test_an_agent_can_list_and_call_tools(
|
||||||
over_mcp, client: TestClient, superuser_token_headers: dict[str, str]
|
over_mcp,
|
||||||
|
client: TestClient,
|
||||||
|
superuser_token_headers: dict[str, str],
|
||||||
|
agent: uuid.UUID,
|
||||||
) -> None:
|
) -> None:
|
||||||
me = client.get(
|
me = client.get(
|
||||||
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
|
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
|
||||||
).json()
|
).json()
|
||||||
token = mcp_token(uuid.UUID(me["id"]))
|
token = mcp_token(uuid.UUID(me["id"]), agent)
|
||||||
|
|
||||||
async def block(http: httpx.AsyncClient) -> tuple[Any, Any]:
|
async def block(http: httpx.AsyncClient) -> tuple[Any, Any]:
|
||||||
listed = await http.post("/mcp", **rpc(token, "tools/list"))
|
listed = await http.post("/mcp", **rpc(token, "tools/list"))
|
||||||
@@ -121,3 +137,36 @@ def test_an_agent_can_list_and_call_tools(
|
|||||||
assert called.status_code == 200
|
assert called.status_code == 200
|
||||||
assert "error" not in called.json()
|
assert "error" not in called.json()
|
||||||
assert called.json()["result"]["isError"] is False
|
assert called.json()["result"]["isError"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoking_an_agent_stops_the_token_it_already_holds(
|
||||||
|
over_mcp,
|
||||||
|
client: TestClient,
|
||||||
|
db: Session,
|
||||||
|
superuser_token_headers: dict[str, str],
|
||||||
|
agent: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
"""Withdrawing an agent has to bite now, not whenever its token expires.
|
||||||
|
|
||||||
|
An access token is a stateless JWT valid for its whole life, so the
|
||||||
|
registration it names is the only thing deleting a client takes away.
|
||||||
|
|
||||||
|
Both calls share one block because the session manager runs once.
|
||||||
|
"""
|
||||||
|
me = client.get(
|
||||||
|
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
|
||||||
|
).json()
|
||||||
|
token = mcp_token(uuid.UUID(me["id"]), agent)
|
||||||
|
|
||||||
|
async def block(http: httpx.AsyncClient) -> tuple[Any, Any]:
|
||||||
|
before = await http.post("/mcp", **rpc(token, "tools/list"))
|
||||||
|
row = db.get(OAuthClient, agent)
|
||||||
|
assert row is not None
|
||||||
|
db.delete(row)
|
||||||
|
db.commit()
|
||||||
|
after = await http.post("/mcp", **rpc(token, "tools/list"))
|
||||||
|
return before, after
|
||||||
|
|
||||||
|
before, after = over_mcp(block)
|
||||||
|
assert before.status_code == 200
|
||||||
|
assert after.status_code == 401
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ function confirms(live: unknown, sent: unknown): boolean {
|
|||||||
* disabled — a dashboard that silently swallows a press looks broken rather
|
* disabled — a dashboard that silently swallows a press looks broken rather
|
||||||
* than locked.
|
* than locked.
|
||||||
*
|
*
|
||||||
* ponytail: this is a read-only surface, not an authorisation boundary. The
|
* Not the boundary, though — the affordance. A panel credential is bounded by
|
||||||
* server still takes a publish from a panel credential whose dashboard says
|
* the same flag on the server (`api/deps.py`, `_panel_writable`), which is
|
||||||
* locked, because the credential's own allowlist is what bounds it. Making it
|
* what refuses a publish from a screen that asks anyway; this is what keeps a
|
||||||
* a real lock means carrying the flag into `_panel_may`.
|
* control from looking pressable when it is not.
|
||||||
*
|
*
|
||||||
* Its own module rather than `widgets.tsx`, which every widget file is
|
* Its own module rather than `widgets.tsx`, which every widget file is
|
||||||
* imported *by*: a control drawn in a file of its own can only reach this
|
* imported *by*: a control drawn in a file of its own can only reach this
|
||||||
|
|||||||
Reference in New Issue
Block a user