feat(m7.3): MCP tenant-scoped bearer tokens #92
Reference in New Issue
Block a user
Delete Branch "feat/m7.3-mcp-tenant-tokens"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
LLM clients (Claude Desktop, Cursor, ChatGPT) can't run Keycloak OIDC, so the MCP server can't gate on JWTs. This PR introduces opaque static bearer tokens minted per-tenant via new agent endpoints, validated by the MCP server, used to route every incoming MCP request to the caller's per-tenant database.
After M7.2, the MCP server was the lone cross-tenant data leak — every tool returned data across all tenants. This closes it.
Design
mcpt_<43 url-safe random chars>(48 total). Opaque, never embeds tenant_id, never stored in plaintext.<prefix>__admin.mcp_tokenscollection, keyed by SHA-256 hash. Carriestenant_id,name,created_by,created_at,last_used_at,revoked.TenantCtx)POST /api/v1/mcp-tokens→ mint (returns raw token once);GET /api/v1/mcp-tokens→ list metadata + 12-char prefix;DELETE /api/v1/mcp-tokens/{id}→ soft revokeAuthorization: Bearer mcpt_..., sniff prefix, SHA-256 → lookup in admin DB → reject if missing or revoked.last_used_atupdates fire-and-forget. Setstokio::task_local!TENANT_IDfor the inner service call.TENANT_ID, resolve to per-tenant DB viapool.for_tenant_id(...), call the tool fn unchanged.Why
task_local!: rmcp's macro-generated tool router gives the handlers&selfand the tool params — no access to request extensions.TENANT_ID.scope(...)wrapsnext.run(req)so handlers downstream see the tenant_id without modifying their signatures.Token UX
Raw token shown once at creation. User copies into their LLM client config. Dashboard UI for management is deferred to a follow-up; you can use curl in the meantime:
Then in Claude Desktop's
claude_desktop_config.json:Test plan
cargo fmt --all -- --checkcleancargo clippy --workspace --exclude compliance-dashboard -- -D warningscleancargo test -p compliance-core --lib— 7 passcargo test -p compliance-agent --lib— 230 pass (+2 new for token generation + sha256 stability)cargo test -p compliance-agent --test tenant_isolation— 6 passcargo test -p compliance-mcp— 34 pass (+1 new sha256 vector against a known python-computed hash)What's deferred
DatabasePoolintocompliance-core— duplicated for now incompliance-mcpto keep this PR focused. Lift if a third consumer appears.Production notes
<prefix>__adminDB name (double underscore) won't collide with<prefix>_<sanitized_tenant_id>for current UUID-shaped tenant_ids. Flagged in the database.rs docstring so future tenant provisioning can reject_admin*ids proactively.MONGODB_URI+MONGODB_DATABASE— no new env needed. No KC creds since MCP doesn't use Keycloak for its own auth (that's the whole point of this PR).https://comp-mcp-dev.meghsakha.com/mcpwithout a bearer will start getting 401. The MCP endpoint was effectively unauthenticated until now; this is the intended security change.🤖 Generated with Claude Code
LLM clients (Claude Desktop, Cursor, ChatGPT) can't run a Keycloak OIDC flow, so the MCP server can't use JWTs for auth. This PR introduces opaque static bearer tokens minted per-tenant via new agent endpoints, validated by the MCP server, and used to route incoming MCP requests to the caller's per-tenant database. Until now, the MCP server connected to a single shared MongoDB DB with no auth and no tenant awareness — every tool (list_findings, list_sbom_packages, etc.) returned data across all tenants. After M7.2 made the agent per-tenant, MCP was the lone cross-tenant data leak. This closes it. Design summary - Token format: `mcpt_<43 url-safe random chars>` (48 chars total). Opaque, never embeds tenant_id, never stored in plaintext. - Storage: cross-tenant `<prefix>__admin.mcp_tokens` collection, keyed by SHA-256 hash. Each row carries the tenant_id, name, created_by, created_at, last_used_at, revoked flag. - Agent endpoints (tenant-scoped via TenantCtx): POST /api/v1/mcp-tokens → mint (returns raw token ONCE) GET /api/v1/mcp-tokens → list (metadata + 12-char prefix, never the hash) DELETE /api/v1/mcp-tokens/id → soft revoke - MCP middleware: extract `Authorization: Bearer mcpt_...`, sniff the prefix, SHA-256 → lookup in admin DB → reject if missing or revoked. Updates last_used_at fire-and-forget so it never blocks. Sets `tokio::task_local!` TENANT_ID for the inner service call; the rmcp tool handlers read it and resolve the per-tenant DB. - task_local is scoped via TENANT_ID.scope(...) around next.run(req) so the rmcp tool handlers downstream see the tenant_id without modifying their (macro-generated) signatures. Files - compliance-core/src/models/mcp_token.rs (new) — McpToken + McpTokenView (public projection without the hash). - compliance-agent/src/database.rs — DatabasePool::admin_db() + admin_db_name(): cross-tenant access for token storage. - compliance-agent/src/api/handlers/mcp_tokens.rs (new) — three endpoints. Token generation: 32 random bytes → URL-safe base64, no padding. SHA-256 hex stored. - compliance-mcp/src/database.rs — replaced single Database with DatabasePool. Tenant-scoped Database constructed per request. Same sanitization + 63-byte cap + hash fallback as the agent. - compliance-mcp/src/auth.rs (new) — bearer middleware + task_local. Includes a SHA-256 round-trip test against a known vector. - compliance-mcp/src/main.rs — HTTP transport: bearer middleware layered on /mcp (not /health, so orca's container probe still works). stdio transport: falls back to STDIO_TENANT_ID env (defaults to "dev") so local development still works; logged loudly as not-for-production. - compliance-mcp/src/server.rs — each of the 12 tool handlers resolves the per-tenant DB via task_local before calling its tool fn. Tool fns themselves are unchanged. Token UX - Generated by the dashboard (or curl + KC JWT) — user sees raw token exactly once, copies it into their LLM client config. - Dashboard UI for management is a follow-up; can use curl in the meantime: curl -X POST https://comp-dev.../api/v1/mcp-tokens \ -H "Authorization: Bearer $KC_JWT" \ -H "Content-Type: application/json" \ -d '{"name":"Claude Desktop"}' Test plan - cargo fmt --all clean - cargo clippy --workspace --exclude compliance-dashboard -- -D warnings clean - cargo test -p compliance-core --lib — 7 pass - cargo test -p compliance-agent --lib — 230 pass (+2 new for token generation + sha256 stability) - cargo test -p compliance-agent --test tenant_isolation — 6 pass - cargo test -p compliance-mcp — 34 pass (+1 new sha256 vector) What's deferred - Dashboard UI for managing tokens (page + create modal + list/ revoke). Trivial once the API is live. - Token expiry + per-tool scope (today every token grants access to all 12 tools for its tenant). - Lifting DatabasePool into compliance-core (duplicated for now in compliance-mcp to keep this PR focused; lift if a third consumer appears). Production - The `<prefix>__admin` DB needs to NOT collide with a tenant DB. Sanitized tenant_id never starts with `_admin` for any current tenant_id shape (UUIDs); flagged in the database.rs docstring so tenant provisioning can reject `_admin*` ids proactively. - orca-infra MCP service block already has MONGODB_URI / MONGODB_DATABASE — no new env needed. No KC creds since MCP doesn't use Keycloak for its own auth. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.