a3a96fe2cc
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.
187 lines
6.1 KiB
Rust
187 lines
6.1 KiB
Rust
//! `/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);
|
|
}
|
|
}
|