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:
@@ -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",
|
||||
|
||||
@@ -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"]})
|
||||
|
||||
@@ -2,25 +2,38 @@
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from fluksio.core import security
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.main import app
|
||||
from fluksio.models import OAuthClient
|
||||
|
||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream"}
|
||||
|
||||
|
||||
def mcp_token(user_id: uuid.UUID) -> str:
|
||||
return security.create_oauth_access_token(
|
||||
user_id, uuid.uuid4(), timedelta(minutes=5)
|
||||
)
|
||||
def mcp_token(user_id: uuid.UUID, client_id: uuid.UUID) -> str:
|
||||
return security.create_oauth_access_token(user_id, client_id, 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
|
||||
@@ -96,12 +109,15 @@ def test_a_browser_token_is_not_an_agent_token(
|
||||
|
||||
|
||||
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:
|
||||
me = client.get(
|
||||
f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers
|
||||
).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]:
|
||||
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 "error" not in called.json()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user