feat(m7.3): MCP tenant-scoped bearer tokens
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Check (pull_request) Successful in 8m9s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
//! `/api/v1/mcp-tokens` — per-tenant API tokens for the MCP server.
|
||||
//!
|
||||
//! These are opaque static bearers issued via the dashboard (or a
|
||||
//! direct curl with a KC JWT) and copied into LLM clients (Claude
|
||||
//! Desktop / Cursor / ChatGPT). The MCP server hashes incoming bearers
|
||||
//! and looks them up in the cross-tenant `<prefix>__admin.mcp_tokens`
|
||||
//! collection to derive the tenant_id for routing.
|
||||
//!
|
||||
//! The raw token is shown to the caller exactly once at creation; the
|
||||
//! database only ever stores the SHA-256 hash. Revocation is a soft
|
||||
//! delete (sets `revoked: true`) so the audit log keeps the record.
|
||||
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Json;
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use compliance_core::models::{McpToken, McpTokenView};
|
||||
use compliance_core::tenant_ctx::TenantCtx;
|
||||
use mongodb::bson::doc;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::dto::{AgentExt, ApiResponse};
|
||||
|
||||
/// Mongo collection name inside the admin DB.
|
||||
const COLLECTION: &str = "mcp_tokens";
|
||||
|
||||
/// Token prefix the MCP server expects on every bearer.
|
||||
const TOKEN_PREFIX: &str = "mcpt_";
|
||||
|
||||
/// Bytes of randomness behind each token. 32 → ~256 bits.
|
||||
/// Encoded as URL-safe base64 without padding → 43 chars.
|
||||
/// Combined with `mcpt_` → 48-char tokens.
|
||||
const TOKEN_RAND_BYTES: usize = 32;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct CreateMcpTokenRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Returned exactly once at creation. The `token` field is gone from
|
||||
/// the listing endpoint — the user must save it now.
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct CreateMcpTokenResponse {
|
||||
pub token: String,
|
||||
pub view: McpTokenView,
|
||||
}
|
||||
|
||||
/// `POST /api/v1/mcp-tokens` — mint a new token for the caller's tenant.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn create_mcp_token(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Json(req): Json<CreateMcpTokenRequest>,
|
||||
) -> Result<Json<CreateMcpTokenResponse>, StatusCode> {
|
||||
if req.name.trim().is_empty() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let raw = generate_token();
|
||||
let token_hash = sha256_hex(&raw);
|
||||
let token_prefix: String = raw.chars().take(12).collect();
|
||||
|
||||
let mut token = McpToken {
|
||||
id: None,
|
||||
token_hash,
|
||||
token_prefix,
|
||||
tenant_id: tenant.0.tenant_id.clone(),
|
||||
name: req.name.trim().to_string(),
|
||||
created_by: tenant.0.user_id.clone(),
|
||||
created_at: chrono::Utc::now(),
|
||||
last_used_at: None,
|
||||
revoked: false,
|
||||
};
|
||||
|
||||
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
|
||||
let res = col.insert_one(&token).await.map_err(|e| {
|
||||
tracing::error!("Failed to insert MCP token: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
token.id = res.inserted_id.as_object_id();
|
||||
|
||||
Ok(Json(CreateMcpTokenResponse {
|
||||
view: McpTokenView::from(&token),
|
||||
token: raw,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/mcp-tokens` — list tokens for the caller's tenant.
|
||||
/// Hash is never returned; only metadata + the 12-char prefix so the
|
||||
/// user can identify which row is which.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn list_mcp_tokens(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
) -> Result<Json<ApiResponse<Vec<McpTokenView>>>, StatusCode> {
|
||||
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
|
||||
let mut cursor = col
|
||||
.find(doc! { "tenant_id": &tenant.0.tenant_id })
|
||||
.sort(doc! { "created_at": -1 })
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to list MCP tokens: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
while cursor.advance().await.map_err(|e| {
|
||||
tracing::warn!("MCP tokens cursor advance failed: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})? {
|
||||
match cursor.deserialize_current() {
|
||||
Ok(t) => out.push(McpTokenView::from(&t)),
|
||||
Err(e) => tracing::warn!("Failed to deserialize MCP token: {e}"),
|
||||
}
|
||||
}
|
||||
Ok(Json(ApiResponse {
|
||||
data: out,
|
||||
total: None,
|
||||
page: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/mcp-tokens/{id}` — revoke (soft delete).
|
||||
/// Scoped to the caller's tenant: a user can't revoke another tenant's
|
||||
/// token even if they guess its id.
|
||||
#[tracing::instrument(skip_all, fields(id = %id))]
|
||||
pub async fn revoke_mcp_token(
|
||||
Extension(agent): AgentExt,
|
||||
tenant: TenantCtx,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
let col = agent.db_pool.admin_db().collection::<McpToken>(COLLECTION);
|
||||
let result = col
|
||||
.update_one(
|
||||
doc! { "_id": oid, "tenant_id": &tenant.0.tenant_id },
|
||||
doc! { "$set": { "revoked": true } },
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to revoke MCP token: {e}");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
if result.matched_count == 0 {
|
||||
return Err(StatusCode::NOT_FOUND);
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "status": "revoked" })))
|
||||
}
|
||||
|
||||
/// 32 bytes random → URL-safe base64 → 43 chars, no padding.
|
||||
/// Prefixed with `mcpt_` so the MCP server can sniff the format
|
||||
/// before bothering with the DB lookup.
|
||||
fn generate_token() -> String {
|
||||
let mut bytes = [0u8; TOKEN_RAND_BYTES];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
fn sha256_hex(s: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(s.as_bytes());
|
||||
hex::encode(h.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_tokens_are_unique_and_prefixed() {
|
||||
let a = generate_token();
|
||||
let b = generate_token();
|
||||
assert_ne!(a, b);
|
||||
assert!(a.starts_with(TOKEN_PREFIX));
|
||||
assert!(b.starts_with(TOKEN_PREFIX));
|
||||
// 5 + 43 = 48 chars
|
||||
assert_eq!(a.len(), 5 + 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_is_stable_and_64_hex() {
|
||||
let h = sha256_hex("mcpt_abc");
|
||||
assert_eq!(h.len(), 64);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(sha256_hex("mcpt_abc"), h);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod graph;
|
||||
pub mod health;
|
||||
pub mod help_chat;
|
||||
pub mod issues;
|
||||
pub mod mcp_tokens;
|
||||
pub mod notifications;
|
||||
pub mod pentest_handlers;
|
||||
pub use pentest_handlers as pentest;
|
||||
|
||||
@@ -47,6 +47,15 @@ pub fn build_router() -> Router {
|
||||
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
|
||||
.route("/api/v1/issues", get(handlers::list_issues))
|
||||
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
||||
// MCP token management (per-tenant API tokens for the MCP server)
|
||||
.route(
|
||||
"/api/v1/mcp-tokens",
|
||||
get(handlers::mcp_tokens::list_mcp_tokens).post(handlers::mcp_tokens::create_mcp_token),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/mcp-tokens/{id}",
|
||||
delete(handlers::mcp_tokens::revoke_mcp_token),
|
||||
)
|
||||
// Graph API endpoints
|
||||
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
|
||||
.route(
|
||||
|
||||
@@ -141,6 +141,25 @@ impl DatabasePool {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// Cross-tenant admin database used by features that intentionally
|
||||
/// span tenants (today: MCP bearer tokens — each token row carries
|
||||
/// a `tenant_id` and the MCP server reads them to route requests).
|
||||
///
|
||||
/// The name `<db_prefix>__admin` (double underscore) is reserved —
|
||||
/// the sanitizer never produces it for a normal tenant DB because
|
||||
/// the natural format is `<db_prefix>_<sanitized_tenant_id>` (one
|
||||
/// underscore) and tenant_ids would have to start with `_admin` to
|
||||
/// collide. New tenant provisioning should reject such ids.
|
||||
pub fn admin_db(&self) -> mongodb::Database {
|
||||
self.client.database(&self.admin_db_name())
|
||||
}
|
||||
|
||||
/// Name of the admin database — public so tests / operators can
|
||||
/// drop it via the raw client.
|
||||
pub fn admin_db_name(&self) -> String {
|
||||
format!("{}__admin", self.db_prefix)
|
||||
}
|
||||
|
||||
/// List every Mongo database currently belonging to this pool,
|
||||
/// identified by the `<db_prefix>_` prefix. The result is the raw
|
||||
/// database names — opening one for offboarding/cleanup goes
|
||||
|
||||
Reference in New Issue
Block a user