mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
e5f8453e1a
Addresses the high follow-up review of the first fix round: Revocation lifecycle (the review's dominant theme): - identity-unlink now purges the user's minted obo cache rows in addition to revoking the credential, and the response/audit report the actual effect (credential + N cache rows) instead of a blanket revoked=true; warmed-session residual (bounded by token TTL) documented - bulk-revoke on obo is now an honest cache-FLUSH: distinct audit event (obo_cache_flushed) + response effect=cache_flush_remints, since the shared credential survives and the next dispatch re-mints (oauth_user keeps its durable revoke semantics) - changing oauth_audience on a pool-backed row now purges cached tokens (audience is the token binding), like URL/name/auth_type changes - flipping oauth_user->oauth_obo now clears the stale AS-consent scopes (else rfc8693 sends them -> invalid_scope loop); write path rejects oauth_scopes under the entra profile (it mints <audience>/.default) - a cache row bearing a refresh token is never served as an obo token (guards the cross-node purge-vs-refresh race) Self-inflicted regression: - _clear_pending_consent_sync is now gated on an in-memory _pending_consent_written hint, so the common successful-dispatch path issues ZERO SQL (was an unconditional per-dispatch DELETE) Observability + cleanups: - restore the obo_mint_rejected log carrying the IdP error text (the shared-helper unification dropped it); event names passed as whole literals so alerting can grep them - persist_rotation typed Callable[[str], Awaitable[None]] (was Any) - _prime_one branches on _obo_server_names (no pre-lookup SQL for oauth_user) - removed now-dead any_oauth_user_mcp_servers (3 impls + tests) +13 regression tests. Full mcp/oidc/console suite 1888 green; mypy clean. Refs #551.
250 lines
8.8 KiB
Python
250 lines
8.8 KiB
Python
"""Integration tests for the Phase 9 admin bulk-revoke endpoint.
|
|
|
|
POST /v1/api/admin/mcp-servers/{name}/bulk-revoke clears every user's
|
|
OAuth token for a server (admin-side counterpart to the per-user
|
|
DELETE /v1/api/mcp/oauth/connections/{server_name} that shipped in
|
|
Phase 8).
|
|
|
|
Coverage:
|
|
- requires ``admin.mcp`` permission (401/403 without).
|
|
- 404 when the named server is missing.
|
|
- 400 when the server's ``auth_type`` is not ``oauth_user``.
|
|
- 200 + ``rows_deleted`` + ``consented_users_before`` on success.
|
|
- Audit row written with
|
|
``upstream_revoke_outcome="bulk_admin_no_upstream"``.
|
|
- Token rows are gone from ``mcp_user_tokens`` post-call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware import Middleware
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.routing import Mount, Route
|
|
from starlette.testclient import TestClient
|
|
|
|
from turnstone.console.server import admin_mcp_bulk_revoke
|
|
from turnstone.core.auth import AuthResult
|
|
from turnstone.core.storage._sqlite import SQLiteBackend
|
|
|
|
if TYPE_CHECKING:
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
|
|
|
|
class _InjectAdminMcp(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="admin-user",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="config",
|
|
permissions=frozenset({"read", "write", "approve", "admin.mcp"}),
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
class _InjectNoAdminMcp(BaseHTTPMiddleware):
|
|
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
|
request.state.auth_result = AuthResult(
|
|
user_id="regular-user",
|
|
scopes=frozenset({"approve"}),
|
|
token_source="jwt",
|
|
permissions=frozenset({"read", "write", "approve"}),
|
|
)
|
|
return await call_next(request)
|
|
|
|
|
|
def _build_app(storage: SQLiteBackend, *, with_admin_mcp: bool = True) -> Starlette:
|
|
mw = _InjectAdminMcp if with_admin_mcp else _InjectNoAdminMcp
|
|
app = Starlette(
|
|
routes=[
|
|
Mount(
|
|
"/v1",
|
|
routes=[
|
|
Route(
|
|
"/api/admin/mcp-servers/{name}/bulk-revoke",
|
|
admin_mcp_bulk_revoke,
|
|
methods=["POST"],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
middleware=[Middleware(mw)],
|
|
)
|
|
app.state.auth_storage = storage
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def storage(tmp_path: Any) -> SQLiteBackend:
|
|
return SQLiteBackend(str(tmp_path / "test.db"))
|
|
|
|
|
|
def _seed_oauth_server(
|
|
backend: SQLiteBackend,
|
|
*,
|
|
name: str = "srv-oauth",
|
|
server_id: str = "srv-oauth-id",
|
|
) -> None:
|
|
backend.create_mcp_server(
|
|
server_id=server_id,
|
|
name=name,
|
|
transport="streamable-http",
|
|
url="https://example.com/mcp",
|
|
auth_type="oauth_user",
|
|
)
|
|
|
|
|
|
def _seed_static_server(
|
|
backend: SQLiteBackend,
|
|
*,
|
|
name: str = "srv-static",
|
|
server_id: str = "srv-static-id",
|
|
) -> None:
|
|
backend.create_mcp_server(
|
|
server_id=server_id,
|
|
name=name,
|
|
transport="streamable-http",
|
|
url="https://example.com/mcp",
|
|
auth_type="static",
|
|
)
|
|
|
|
|
|
def _seed_user_tokens(backend: SQLiteBackend, server_name: str, users: int) -> None:
|
|
for i in range(users):
|
|
backend.create_mcp_user_token(
|
|
f"user-{i}",
|
|
server_name,
|
|
access_token_ct=b"ct",
|
|
refresh_token_ct=None,
|
|
expires_at=None,
|
|
scopes=None,
|
|
as_issuer="https://as.example.com",
|
|
audience="https://example.com/mcp",
|
|
)
|
|
|
|
|
|
def test_requires_admin_mcp_permission(storage: SQLiteBackend) -> None:
|
|
_seed_oauth_server(storage)
|
|
client = TestClient(_build_app(storage, with_admin_mcp=False))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_404_on_missing_server(storage: SQLiteBackend) -> None:
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/never-existed/bulk-revoke")
|
|
assert resp.status_code == 404
|
|
assert resp.json() == {"error": "No such server"}
|
|
|
|
|
|
def test_400_on_static_server(storage: SQLiteBackend) -> None:
|
|
_seed_static_server(storage)
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-static/bulk-revoke")
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert "oauth_user" in body["error"]
|
|
|
|
|
|
def test_400_on_invalid_server_name(storage: SQLiteBackend) -> None:
|
|
# double-underscore is reserved for the prefixed-tool-name encoding.
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/bad__name/bulk-revoke")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_200_on_oauth_obo_server(storage: SQLiteBackend) -> None:
|
|
"""#551: bulk-revoke serves oauth_obo too (it populates mcp_user_tokens with
|
|
minted cache rows) — the documented remediation for stale rows after an
|
|
oauth_user→oauth_obo flip."""
|
|
storage.create_mcp_server(
|
|
server_id="srv-obo-id",
|
|
name="srv-obo",
|
|
transport="streamable-http",
|
|
url="https://example.com/mcp",
|
|
auth_type="oauth_obo",
|
|
)
|
|
_seed_user_tokens(storage, "srv-obo", users=2)
|
|
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-obo/bulk-revoke")
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["rows_deleted"] == 2
|
|
assert storage.count_mcp_consented_users_by_server("srv-obo") == 0
|
|
# Honest semantics: obo is a cache flush (re-mints), NOT a consent revoke.
|
|
assert body["effect"] == "cache_flush_remints"
|
|
events = storage.list_audit_events(action="mcp_server.oauth.obo_cache_flushed")
|
|
assert len(events) == 1
|
|
# The oauth_user revoke event must NOT be emitted for an obo flush.
|
|
assert storage.list_audit_events(action="mcp_server.oauth.bulk_revoked") == []
|
|
|
|
|
|
def test_oauth_user_bulk_revoke_keeps_revoke_semantics(storage: SQLiteBackend) -> None:
|
|
"""The oauth_user path is unchanged: durable revoke event + effect."""
|
|
_seed_oauth_server(storage)
|
|
_seed_user_tokens(storage, "srv-oauth", users=1)
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
|
|
assert resp.status_code == 200, resp.text
|
|
assert resp.json()["effect"] == "revoked_until_reconsent"
|
|
assert len(storage.list_audit_events(action="mcp_server.oauth.bulk_revoked")) == 1
|
|
|
|
|
|
def test_200_on_success_with_no_consented_users(storage: SQLiteBackend) -> None:
|
|
_seed_oauth_server(storage)
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "ok"
|
|
assert body["rows_deleted"] == 0
|
|
assert body["consented_users_before"] == 0
|
|
|
|
|
|
def test_200_clears_all_user_tokens(storage: SQLiteBackend) -> None:
|
|
_seed_oauth_server(storage)
|
|
_seed_user_tokens(storage, "srv-oauth", users=3)
|
|
# Token for another server must survive the bulk-revoke.
|
|
_seed_oauth_server(storage, name="srv-other", server_id="srv-other-id")
|
|
_seed_user_tokens(storage, "srv-other", users=2)
|
|
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["status"] == "ok"
|
|
assert body["rows_deleted"] == 3
|
|
assert body["consented_users_before"] == 3
|
|
|
|
# Target server's tokens are gone; bystander's tokens survive.
|
|
assert storage.count_mcp_consented_users_by_server("srv-oauth") == 0
|
|
assert storage.count_mcp_consented_users_by_server("srv-other") == 2
|
|
|
|
|
|
def test_audits_with_bulk_admin_no_upstream(storage: SQLiteBackend) -> None:
|
|
_seed_oauth_server(storage)
|
|
_seed_user_tokens(storage, "srv-oauth", users=2)
|
|
client = TestClient(_build_app(storage))
|
|
resp = client.post("/v1/api/admin/mcp-servers/srv-oauth/bulk-revoke")
|
|
assert resp.status_code == 200
|
|
|
|
# Pull the most-recent audit row for the bulk_revoked action and
|
|
# verify it carries the deferral marker.
|
|
events = storage.list_audit_events(limit=10)
|
|
bulk_rows = [e for e in events if e.get("action") == "mcp_server.oauth.bulk_revoked"]
|
|
assert len(bulk_rows) == 1
|
|
detail = bulk_rows[0].get("detail")
|
|
if isinstance(detail, str):
|
|
import json as _json
|
|
|
|
detail = _json.loads(detail)
|
|
assert detail.get("upstream_revoke_outcome") == "bulk_admin_no_upstream"
|
|
assert detail.get("rows_deleted") == 2
|
|
assert detail.get("consented_users_before") == 2
|
|
assert detail.get("name") == "srv-oauth"
|