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
+56 -7
View File
@@ -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