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:
@@ -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