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
+11
View File
@@ -270,6 +270,17 @@ def test_one_agent_can_be_revoked_without_touching_the_others(
assert revoked.status_code == 200, revoked.text
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.
refreshed = client.post(
f"{PREFIX}/token",
+85 -2
View File
@@ -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(
client: TestClient, superuser_token_headers: dict[str, str]
) -> 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.volatile = VolatileStore(tmp_path / "ring", limit_bytes=1_000_000)
frame = store.put([b"\x89PNG..."], name="f.png", media_type="image/png",
volatile=True)
frame = store.put(
[b"\x89PNG..."], name="f.png", media_type="image/png", volatile=True
)
kept = store.put([b"checkpoint"], name="w.pt")
asking = orjson.dumps({"type": "media", "names": ["cam.frame", "other.frame"]})