feat(m7.3): MCP tenant-scoped bearer tokens (#92)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 8m13s
CI / Deploy Dashboard (push) Successful in 7m3s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m50s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 8m13s
CI / Deploy Dashboard (push) Successful in 7m3s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m50s
MCP server validates per-tenant bearer tokens on incoming calls and routes each tool to the caller's tenant DB. Closes the cross-tenant data leak in the MCP path identified in M7.3.
This commit was merged in pull request #92.
This commit is contained in:
@@ -42,6 +42,7 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-core = "0.3"
|
||||
dashmap = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,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;
|
||||
|
||||
@@ -46,6 +46,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