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.
155 lines
4.9 KiB
Rust
155 lines
4.9 KiB
Rust
//! Per-tenant Mongo broker for the MCP server.
|
|
//!
|
|
//! Mirror of the agent's `compliance_agent::database::DatabasePool` —
|
|
//! duplicated here rather than lifted into `compliance-core` to keep
|
|
//! this PR focused. If a third consumer ever needs it, lift then.
|
|
//!
|
|
//! Bearer tokens (validated by the auth middleware) carry a tenant_id
|
|
//! and the handler resolves the per-tenant database via
|
|
//! [`DatabasePool::for_tenant_id`]. The admin database
|
|
//! (`<db_prefix>__admin`) holds the cross-tenant `mcp_tokens`
|
|
//! collection that the middleware queries on every request.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use dashmap::DashMap;
|
|
use mongodb::{bson::doc, Client, Collection};
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use compliance_core::models::*;
|
|
|
|
/// 63-byte Mongo db-name cap; same invariant as the agent's pool.
|
|
const MAX_DB_NAME_LEN: usize = 63;
|
|
/// 16-byte SHA-256 truncation, hex-encoded → 32 chars.
|
|
const HASH_HEX_LEN: usize = 32;
|
|
const MAX_PREFIX_LEN: usize = MAX_DB_NAME_LEN - 1 - HASH_HEX_LEN;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DatabasePool {
|
|
client: Client,
|
|
db_prefix: String,
|
|
/// Tenants we've handed out a [`Database`] for. The MCP server
|
|
/// doesn't ensure indexes (the agent owns that side of the
|
|
/// schema), so the marker exists only to satisfy the parallel
|
|
/// shape — current code never reads it.
|
|
#[allow(dead_code)]
|
|
seen: Arc<DashMap<String, ()>>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum DbError {
|
|
#[error("db_prefix '{prefix}' is {len} chars; max is {max} so the hash-fallback DB name fits Mongo's 63-byte cap")]
|
|
PrefixTooLong {
|
|
prefix: String,
|
|
len: usize,
|
|
max: usize,
|
|
},
|
|
#[error(transparent)]
|
|
Mongo(#[from] mongodb::error::Error),
|
|
}
|
|
|
|
impl DatabasePool {
|
|
pub async fn connect(uri: &str, db_prefix: &str) -> Result<Self, DbError> {
|
|
if db_prefix.len() > MAX_PREFIX_LEN {
|
|
return Err(DbError::PrefixTooLong {
|
|
prefix: db_prefix.to_string(),
|
|
len: db_prefix.len(),
|
|
max: MAX_PREFIX_LEN,
|
|
});
|
|
}
|
|
let client = Client::with_uri_str(uri).await?;
|
|
client
|
|
.database("admin")
|
|
.run_command(doc! { "ping": 1 })
|
|
.await?;
|
|
tracing::info!(
|
|
"MCP MongoDB cluster reachable; per-tenant pool ready (db prefix '{db_prefix}')"
|
|
);
|
|
Ok(Self {
|
|
client,
|
|
db_prefix: db_prefix.to_string(),
|
|
seen: Arc::new(DashMap::new()),
|
|
})
|
|
}
|
|
|
|
/// Read-only handle to the tenant's database. No indexes are
|
|
/// ensured here — the agent owns writes, MCP only reads.
|
|
pub fn for_tenant_id(&self, tenant_id: &str) -> Database {
|
|
let db_name = self.tenant_db_name(tenant_id);
|
|
self.seen.insert(tenant_id.to_string(), ());
|
|
Database::new(self.client.database(&db_name))
|
|
}
|
|
|
|
/// Cross-tenant admin DB — holds the `mcp_tokens` collection that
|
|
/// the auth middleware queries to map bearer → tenant_id.
|
|
pub fn admin_db(&self) -> mongodb::Database {
|
|
self.client.database(&format!("{}__admin", self.db_prefix))
|
|
}
|
|
|
|
pub fn tenant_db_name(&self, tenant_id: &str) -> String {
|
|
let sanitized = sanitize_tenant_id(tenant_id);
|
|
let natural = format!("{}_{}", self.db_prefix, sanitized);
|
|
if natural.len() <= MAX_DB_NAME_LEN {
|
|
natural
|
|
} else {
|
|
let mut h = Sha256::new();
|
|
h.update(tenant_id.as_bytes());
|
|
let digest = h.finalize();
|
|
let suffix = hex::encode(&digest[..HASH_HEX_LEN / 2]);
|
|
format!("{}_{}", self.db_prefix, suffix)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn sanitize_tenant_id(tenant_id: &str) -> String {
|
|
tenant_id
|
|
.chars()
|
|
.map(|c| match c {
|
|
'/' | '\\' | '.' | '"' | '$' | ' ' | '\0' => '_',
|
|
c => c,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Typed accessors for the MCP-readable collections in a tenant DB.
|
|
/// Matches the agent's `Database` shape but only exposes what the MCP
|
|
/// tool handlers actually need.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Database {
|
|
inner: mongodb::Database,
|
|
}
|
|
|
|
impl Database {
|
|
pub(crate) fn new(inner: mongodb::Database) -> Self {
|
|
Self { inner }
|
|
}
|
|
|
|
pub fn findings(&self) -> Collection<Finding> {
|
|
self.inner.collection("findings")
|
|
}
|
|
|
|
pub fn sbom_entries(&self) -> Collection<SbomEntry> {
|
|
self.inner.collection("sbom_entries")
|
|
}
|
|
|
|
pub fn dast_findings(&self) -> Collection<DastFinding> {
|
|
self.inner.collection("dast_findings")
|
|
}
|
|
|
|
pub fn dast_scan_runs(&self) -> Collection<DastScanRun> {
|
|
self.inner.collection("dast_scan_runs")
|
|
}
|
|
|
|
pub fn pentest_sessions(&self) -> Collection<PentestSession> {
|
|
self.inner.collection("pentest_sessions")
|
|
}
|
|
|
|
pub fn attack_chain_nodes(&self) -> Collection<AttackChainNode> {
|
|
self.inner.collection("attack_chain_nodes")
|
|
}
|
|
|
|
pub fn pentest_messages(&self) -> Collection<PentestMessage> {
|
|
self.inner.collection("pentest_messages")
|
|
}
|
|
}
|