Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcec519565 | |||
| 08c4ec4cff | |||
| 0f6dd1135e | |||
| cdfbb62f9d | |||
| 003835764e | |||
| e3aabe7d18 |
@@ -7,17 +7,4 @@ ignore = [
|
|||||||
# not a realistic attack surface here. Revisit when mongodb bumps hickory.
|
# not a realistic attack surface here. Revisit when mongodb bumps hickory.
|
||||||
"RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream
|
"RUSTSEC-2026-0118", # NSEC3 loop, no fix available upstream
|
||||||
"RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1
|
"RUSTSEC-2026-0119", # O(n²) name compression, fixed in hickory-proto >=0.26.1
|
||||||
|
|
||||||
# rmcp 0.16.0 — DNS rebinding in Streamable HTTP server transport (missing
|
|
||||||
# Host header validation). Patched in rmcp >= 1.4.0, which is a major API
|
|
||||||
# version jump from our pin; rmcp shipped 0.x → 1.x → 2.x in three months
|
|
||||||
# and the migration touches every tool handler + the auth middleware we
|
|
||||||
# just landed in #92. Threat model in our deployment: the MCP server is
|
|
||||||
# exposed at a public hostname (comp-mcp-dev.meghsakha.com) behind orca's
|
|
||||||
# TLS-terminating ingress with per-tenant bearer auth — the attack model
|
|
||||||
# (browser DNS-rebinding into localhost MCP server) doesn't directly apply.
|
|
||||||
# Defense-in-depth Host-header check is still a worthwhile follow-up.
|
|
||||||
# FOLLOW-UP: bump rmcp to 2.x in a dedicated PR (M7.3 follow-up, sized
|
|
||||||
# multi-hour due to API surface change).
|
|
||||||
"RUSTSEC-2026-0189",
|
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+2
-6
@@ -676,7 +676,6 @@ dependencies = [
|
|||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"octocrab",
|
"octocrab",
|
||||||
"rand 0.9.2",
|
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
@@ -819,15 +818,12 @@ dependencies = [
|
|||||||
"bson",
|
"bson",
|
||||||
"chrono",
|
"chrono",
|
||||||
"compliance-core",
|
"compliance-core",
|
||||||
"dashmap",
|
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"hex",
|
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -4282,9 +4278,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.15"
|
version = "0.11.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
|
|||||||
@@ -34,5 +34,3 @@ zip = { version = "2", features = ["aes-crypto", "deflate"] }
|
|||||||
dashmap = "6"
|
dashmap = "6"
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
aes-gcm = "0.10"
|
aes-gcm = "0.10"
|
||||||
rand = "0.9"
|
|
||||||
base64 = "0.22"
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
|||||||
futures-core = "0.3"
|
futures-core = "0.3"
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
tokio-stream = { workspace = true }
|
tokio-stream = { workspace = true }
|
||||||
rand = { workspace = true }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
|
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
//! Cross-tenant admin endpoints (`/api/v1/admin/*`).
|
|
||||||
//!
|
|
||||||
//! Operator-only. Auth is a **static bearer token** (`ADMIN_API_TOKEN`
|
|
||||||
//! env on the agent) — explicitly NOT a Keycloak JWT, because the
|
|
||||||
//! whole point of these endpoints is to operate ACROSS tenants. A
|
|
||||||
//! customer JWT (which always carries a single tenant_id) has no
|
|
||||||
//! business mounting them.
|
|
||||||
//!
|
|
||||||
//! Routes are only registered when `ADMIN_API_TOKEN` is set. With no
|
|
||||||
//! token, the endpoints don't exist at all (404), which is a stronger
|
|
||||||
//! guarantee than "401 if you guess the path".
|
|
||||||
//!
|
|
||||||
//! Operations:
|
|
||||||
//! - `GET /api/v1/admin/tenants` — list tenant DBs
|
|
||||||
//! - `DELETE /api/v1/admin/tenants/{tenant_id}` — GDPR delete
|
|
||||||
//!
|
|
||||||
//! Tenant ids in URLs are passed as-is to `DatabasePool::drop_tenant`,
|
|
||||||
//! which sanitises them the same way it does for creation. Listing
|
|
||||||
//! returns the raw DB names from `list_tenant_db_names` — operators
|
|
||||||
//! can reverse-derive the tenant_id from the prefix.
|
|
||||||
|
|
||||||
use axum::extract::{Extension, Path, Request};
|
|
||||||
use axum::http::{header, StatusCode};
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use axum::Json;
|
|
||||||
use secrecy::ExposeSecret;
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use super::dto::AgentExt;
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct ListTenantDbsResponse {
|
|
||||||
pub tenant_db_names: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip_all)]
|
|
||||||
pub async fn list_tenant_dbs(
|
|
||||||
Extension(agent): AgentExt,
|
|
||||||
) -> Result<Json<ListTenantDbsResponse>, StatusCode> {
|
|
||||||
let names = agent.db_pool.list_tenant_db_names().await.map_err(|e| {
|
|
||||||
tracing::error!("admin: list_tenant_db_names failed: {e}");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
Ok(Json(ListTenantDbsResponse {
|
|
||||||
tenant_db_names: names,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip_all, fields(tenant_id = %tenant_id))]
|
|
||||||
pub async fn drop_tenant_db(
|
|
||||||
Extension(agent): AgentExt,
|
|
||||||
Path(tenant_id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
||||||
agent.db_pool.drop_tenant(&tenant_id).await.map_err(|e| {
|
|
||||||
tracing::error!("admin: drop_tenant failed: {e}");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
Ok(Json(serde_json::json!({ "status": "dropped" })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Constant-time-ish comparison of the configured admin token against
|
|
||||||
/// the incoming bearer. Uses `subtle`-style byte equality so timing
|
|
||||||
/// attacks can't probe the token character by character.
|
|
||||||
fn tokens_eq(a: &str, b: &str) -> bool {
|
|
||||||
if a.len() != b.len() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let mut diff = 0u8;
|
|
||||||
for (x, y) in a.bytes().zip(b.bytes()) {
|
|
||||||
diff |= x ^ y;
|
|
||||||
}
|
|
||||||
diff == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Middleware enforcing the static `ADMIN_API_TOKEN`. Mounted only on
|
|
||||||
/// the admin sub-router, so this never runs on customer routes.
|
|
||||||
pub async fn require_admin_token(
|
|
||||||
Extension(agent): AgentExt,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let Some(expected) = agent.config.admin_api_token.as_ref() else {
|
|
||||||
// Belt-and-braces — if the routes were somehow mounted without
|
|
||||||
// a token configured, refuse rather than no-op-pass.
|
|
||||||
return (StatusCode::NOT_FOUND, "admin disabled").into_response();
|
|
||||||
};
|
|
||||||
let presented = request
|
|
||||||
.headers()
|
|
||||||
.get(header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|s| s.strip_prefix("Bearer "))
|
|
||||||
.map(|s| s.trim());
|
|
||||||
let Some(presented) = presented.filter(|s| !s.is_empty()) else {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
|
|
||||||
};
|
|
||||||
if !tokens_eq(presented, expected.expose_secret()) {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Invalid admin token").into_response();
|
|
||||||
}
|
|
||||||
next.run(request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tokens_eq_basic() {
|
|
||||||
assert!(tokens_eq("abc", "abc"));
|
|
||||||
assert!(!tokens_eq("abc", "abd"));
|
|
||||||
assert!(!tokens_eq("abc", "abcd"));
|
|
||||||
assert!(!tokens_eq("", "x"));
|
|
||||||
assert!(tokens_eq("", ""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
//! `/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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod admin;
|
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod dto;
|
pub mod dto;
|
||||||
@@ -7,7 +6,6 @@ pub mod graph;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod help_chat;
|
pub mod help_chat;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod mcp_tokens;
|
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod pentest_handlers;
|
pub mod pentest_handlers;
|
||||||
pub use pentest_handlers as pentest;
|
pub use pentest_handlers as pentest;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use axum::routing::{delete, get, patch, post};
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::api::handlers;
|
use crate::api::handlers;
|
||||||
|
use crate::webhooks;
|
||||||
|
|
||||||
pub fn build_router() -> Router {
|
pub fn build_router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -46,15 +47,6 @@ pub fn build_router() -> Router {
|
|||||||
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
|
.route("/api/v1/sbom/diff", get(handlers::sbom_diff))
|
||||||
.route("/api/v1/issues", get(handlers::list_issues))
|
.route("/api/v1/issues", get(handlers::list_issues))
|
||||||
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
.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
|
// Graph API endpoints
|
||||||
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
|
.route("/api/v1/graph/{repo_id}", get(handlers::graph::get_graph))
|
||||||
.route(
|
.route(
|
||||||
@@ -183,10 +175,17 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/pentest/stats",
|
"/api/v1/pentest/stats",
|
||||||
get(handlers::pentest::pentest_stats),
|
get(handlers::pentest::pentest_stats),
|
||||||
)
|
)
|
||||||
// Webhook routes live on the separate webhook server (port 3002,
|
// Webhook endpoints (proxied through dashboard)
|
||||||
// see crate::webhooks::server). The M7.2-C tenant-in-URL form is
|
.route(
|
||||||
// `/webhook/{tenant_id}/{platform}/{repo_id}` and the handlers
|
"/webhook/github/{repo_id}",
|
||||||
// expect a (tenant_id, repo_id) path tuple. Anything mounting
|
post(webhooks::github::handle_github_webhook),
|
||||||
// them here on the API server would mismatch the handler
|
)
|
||||||
// signature, so the routes are not exported.
|
.route(
|
||||||
|
"/webhook/gitlab/{repo_id}",
|
||||||
|
post(webhooks::gitlab::handle_gitlab_webhook),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/webhook/gitea/{repo_id}",
|
||||||
|
post(webhooks::gitea::handle_gitea_webhook),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ use axum::extract::Request;
|
|||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use axum::routing::{delete, get};
|
use axum::{middleware, Extension};
|
||||||
use axum::{middleware, Extension, Router};
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
@@ -15,7 +14,6 @@ use compliance_core::auth::{require_jwt_auth, require_tenant_status, JwksState};
|
|||||||
use compliance_core::{TenantContext, TenantStatus};
|
use compliance_core::{TenantContext, TenantStatus};
|
||||||
|
|
||||||
use crate::agent::ComplianceAgent;
|
use crate::agent::ComplianceAgent;
|
||||||
use crate::api::handlers;
|
|
||||||
use crate::api::routes;
|
use crate::api::routes;
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
@@ -52,28 +50,7 @@ pub async fn inject_dev_tenant(mut request: Request, next: Next) -> Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
||||||
// Admin sub-router. Routes are only mounted when ADMIN_API_TOKEN is
|
|
||||||
// configured — without it, the paths don't exist at all (404 rather
|
|
||||||
// than 401), so an operator who hasn't opted in can't fingerprint
|
|
||||||
// the surface area.
|
|
||||||
let admin_router: Router = if agent.config.admin_api_token.is_some() {
|
|
||||||
tracing::info!("Admin API enabled — /api/v1/admin/* mounted behind ADMIN_API_TOKEN bearer");
|
|
||||||
Router::new()
|
|
||||||
.route(
|
|
||||||
"/api/v1/admin/tenants",
|
|
||||||
get(handlers::admin::list_tenant_dbs),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/v1/admin/tenants/{tenant_id}",
|
|
||||||
delete(handlers::admin::drop_tenant_db),
|
|
||||||
)
|
|
||||||
.layer(middleware::from_fn(handlers::admin::require_admin_token))
|
|
||||||
} else {
|
|
||||||
Router::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut app = routes::build_router()
|
let mut app = routes::build_router()
|
||||||
.merge(admin_router)
|
|
||||||
.layer(Extension(Arc::new(agent.clone())))
|
.layer(Extension(Arc::new(agent.clone())))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
|
|||||||
@@ -59,7 +59,5 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
.unwrap_or(true),
|
.unwrap_or(true),
|
||||||
pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"),
|
pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"),
|
||||||
pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"),
|
pentest_imap_password: env_secret_opt("PENTEST_IMAP_PASSWORD"),
|
||||||
admin_api_token: env_secret_opt("ADMIN_API_TOKEN"),
|
|
||||||
tenant_registry_url: env_var_opt("TENANT_REGISTRY_URL"),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,25 +141,6 @@ impl DatabasePool {
|
|||||||
&self.client
|
&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,
|
/// List every Mongo database currently belonging to this pool,
|
||||||
/// identified by the `<db_prefix>_` prefix. The result is the raw
|
/// identified by the `<db_prefix>_` prefix. The result is the raw
|
||||||
/// database names — opening one for offboarding/cleanup goes
|
/// database names — opening one for offboarding/cleanup goes
|
||||||
|
|||||||
@@ -339,8 +339,6 @@ mod tests {
|
|||||||
pentest_imap_tls: true,
|
pentest_imap_tls: true,
|
||||||
pentest_imap_username: None,
|
pentest_imap_username: None,
|
||||||
pentest_imap_password: None,
|
pentest_imap_password: None,
|
||||||
admin_api_token: None,
|
|
||||||
tenant_registry_url: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,11 @@ use crate::agent::ComplianceAgent;
|
|||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
/// Default tenant the scheduler runs against when neither the tenant
|
/// Default tenant the scheduler runs against when `SCHEDULER_TENANT_IDS`
|
||||||
/// registry nor `SCHEDULER_TENANT_IDS` are configured. Matches the
|
/// isn't set. Matches the dev-injector default so a bare `cargo run` has
|
||||||
/// dev-injector default so a bare `cargo run` has the scheduler
|
/// the scheduler scanning whatever lives in `<prefix>_dev`.
|
||||||
/// scanning whatever lives in `<prefix>_dev`.
|
|
||||||
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
|
const DEFAULT_SCHEDULER_TENANT_ID: &str = "dev";
|
||||||
|
|
||||||
/// Request timeout when fetching the live tenant list from the
|
|
||||||
/// registry. Kept short — if the registry is slow we'd rather fall
|
|
||||||
/// back to env-configured ids and finish the tick than block the
|
|
||||||
/// scheduler loop.
|
|
||||||
const REGISTRY_FETCH_TIMEOUT_SECS: u64 = 5;
|
|
||||||
|
|
||||||
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
|
pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError> {
|
||||||
let sched = JobScheduler::new()
|
let sched = JobScheduler::new()
|
||||||
.await
|
.await
|
||||||
@@ -31,12 +24,7 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
|||||||
let agent = scan_agent.clone();
|
let agent = scan_agent.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
tracing::info!("Scheduled scan triggered");
|
tracing::info!("Scheduled scan triggered");
|
||||||
let tenants = scheduler_tenants(&agent).await;
|
for tenant_id in scheduler_tenants() {
|
||||||
tracing::debug!(
|
|
||||||
tenant_count = tenants.len(),
|
|
||||||
"Scheduled scan: tenants resolved"
|
|
||||||
);
|
|
||||||
for tenant_id in tenants {
|
|
||||||
scan_all_repos(&agent, &tenant_id).await;
|
scan_all_repos(&agent, &tenant_id).await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -54,12 +42,7 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
|||||||
let agent = cve_agent.clone();
|
let agent = cve_agent.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
tracing::info!("CVE monitor triggered");
|
tracing::info!("CVE monitor triggered");
|
||||||
let tenants = scheduler_tenants(&agent).await;
|
for tenant_id in scheduler_tenants() {
|
||||||
tracing::debug!(
|
|
||||||
tenant_count = tenants.len(),
|
|
||||||
"CVE monitor: tenants resolved"
|
|
||||||
);
|
|
||||||
for tenant_id in tenants {
|
|
||||||
monitor_cves(&agent, &tenant_id).await;
|
monitor_cves(&agent, &tenant_id).await;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -75,14 +58,9 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
|
.map_err(|e| AgentError::Scheduler(format!("Failed to start scheduler: {e}")))?;
|
||||||
|
|
||||||
let tenants = scheduler_tenants(agent).await;
|
let tenants = scheduler_tenants();
|
||||||
let source = if agent.config.tenant_registry_url.is_some() {
|
|
||||||
"tenant-registry (env fallback)"
|
|
||||||
} else {
|
|
||||||
"env (SCHEDULER_TENANT_IDS)"
|
|
||||||
};
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"Scheduler started: scans='{}', CVE monitor='{}', tenant source={source}, tenants={tenants:?}",
|
"Scheduler started: scans='{}', CVE monitor='{}', tenants={tenants:?}",
|
||||||
agent.config.scan_schedule,
|
agent.config.scan_schedule,
|
||||||
agent.config.cve_monitor_schedule,
|
agent.config.cve_monitor_schedule,
|
||||||
);
|
);
|
||||||
@@ -93,40 +71,10 @@ pub async fn start_scheduler(agent: &ComplianceAgent) -> Result<(), AgentError>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenants the scheduler iterates each tick.
|
/// Tenants the scheduler iterates each tick. From `SCHEDULER_TENANT_IDS`
|
||||||
///
|
/// (comma-separated), or `DEFAULT_SCHEDULER_TENANT_ID` if unset. M7.2-D
|
||||||
/// Resolution order:
|
/// will replace this with a pull from the tenant-registry.
|
||||||
/// 1. **Tenant registry** at `agent.config.tenant_registry_url`
|
fn scheduler_tenants() -> Vec<String> {
|
||||||
/// (`GET /v1/tenants`). Fresh on every tick — picks up newly
|
|
||||||
/// provisioned tenants without an agent restart.
|
|
||||||
/// 2. **`SCHEDULER_TENANT_IDS`** env (comma-separated) — fallback when
|
|
||||||
/// the registry is unreachable, the response is malformed, or no
|
|
||||||
/// registry URL is configured.
|
|
||||||
/// 3. **`DEFAULT_SCHEDULER_TENANT_ID`** (`"dev"`) — last-ditch fallback
|
|
||||||
/// so the scheduler keeps doing something useful in dev.
|
|
||||||
///
|
|
||||||
/// We never panic out of this function — the scheduler must keep
|
|
||||||
/// firing even if the registry is offline.
|
|
||||||
async fn scheduler_tenants(agent: &ComplianceAgent) -> Vec<String> {
|
|
||||||
if let Some(url) = agent.config.tenant_registry_url.as_deref() {
|
|
||||||
match fetch_tenants_from_registry(&agent.http, url).await {
|
|
||||||
Ok(v) if !v.is_empty() => return v,
|
|
||||||
Ok(_) => {
|
|
||||||
tracing::warn!("tenant-registry returned empty list; falling back to env");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
url = %url,
|
|
||||||
error = %e,
|
|
||||||
"tenant-registry fetch failed; falling back to env"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tenants_from_env()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tenants_from_env() -> Vec<String> {
|
|
||||||
std::env::var("SCHEDULER_TENANT_IDS")
|
std::env::var("SCHEDULER_TENANT_IDS")
|
||||||
.ok()
|
.ok()
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
@@ -140,134 +88,6 @@ fn tenants_from_env() -> Vec<String> {
|
|||||||
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
|
.unwrap_or_else(|| vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shape we accept from the registry. Liberal in what we accept:
|
|
||||||
/// the registry can return any field shape as long as either `id` or
|
|
||||||
/// `tenant_id` is present. Other fields are ignored.
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct RegistryTenant {
|
|
||||||
#[serde(alias = "tenant_id")]
|
|
||||||
id: String,
|
|
||||||
/// Filter out non-running tenants if status is present. Missing
|
|
||||||
/// status defaults to "active" so older registry deployments keep
|
|
||||||
/// working.
|
|
||||||
#[serde(default = "default_status")]
|
|
||||||
status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_status() -> String {
|
|
||||||
"active".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct RegistryListResponse {
|
|
||||||
data: Vec<RegistryTenant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_tenants_from_registry(
|
|
||||||
http: &reqwest::Client,
|
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<String>, String> {
|
|
||||||
let url = format!("{}/v1/tenants", base_url.trim_end_matches('/'));
|
|
||||||
let resp = http
|
|
||||||
.get(&url)
|
|
||||||
.timeout(std::time::Duration::from_secs(REGISTRY_FETCH_TIMEOUT_SECS))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("request failed: {e}"))?;
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return Err(format!("registry returned {}", resp.status()));
|
|
||||||
}
|
|
||||||
let body: RegistryListResponse = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("invalid JSON: {e}"))?;
|
|
||||||
Ok(filter_active(body.data))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Frozen/Archived tenants don't need scheduled scans; the M7.1
|
|
||||||
/// status gate would 402/410 anyway. Skip them so we don't waste
|
|
||||||
/// cycles. Active / trial / demo / anything-else-unknown all run.
|
|
||||||
fn filter_active(rows: Vec<RegistryTenant>) -> Vec<String> {
|
|
||||||
rows.into_iter()
|
|
||||||
.filter(|t| !matches!(t.status.as_str(), "frozen" | "archived"))
|
|
||||||
.map(|t| t.id)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn tenant(id: &str, status: &str) -> RegistryTenant {
|
|
||||||
RegistryTenant {
|
|
||||||
id: id.to_string(),
|
|
||||||
status: status.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filter_active_keeps_running_skips_frozen_archived() {
|
|
||||||
let rows = vec![
|
|
||||||
tenant("a", "active"),
|
|
||||||
tenant("b", "trial"),
|
|
||||||
tenant("c", "demo"),
|
|
||||||
tenant("d", "frozen"),
|
|
||||||
tenant("e", "archived"),
|
|
||||||
tenant("f", "weird-but-not-known-dead"),
|
|
||||||
];
|
|
||||||
let out = filter_active(rows);
|
|
||||||
assert_eq!(out, vec!["a", "b", "c", "f"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn deserialize_registry_response_accepts_id_or_tenant_id() {
|
|
||||||
let body = r#"{"data":[
|
|
||||||
{"id":"a","status":"active"},
|
|
||||||
{"tenant_id":"b","status":"trial"},
|
|
||||||
{"id":"c"}
|
|
||||||
]}"#;
|
|
||||||
let parsed: RegistryListResponse = serde_json::from_str(body).unwrap();
|
|
||||||
assert_eq!(parsed.data.len(), 3);
|
|
||||||
assert_eq!(parsed.data[0].id, "a");
|
|
||||||
assert_eq!(parsed.data[1].id, "b");
|
|
||||||
assert_eq!(parsed.data[2].id, "c");
|
|
||||||
// Default status for the third entry should be "active"
|
|
||||||
assert_eq!(parsed.data[2].status, "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Combined into a single test: cargo runs tests in parallel and
|
|
||||||
/// env vars are process-global, so two separate tests touching
|
|
||||||
/// `SCHEDULER_TENANT_IDS` race each other. Doing both checks in
|
|
||||||
/// one test keeps them in a deterministic order.
|
|
||||||
#[test]
|
|
||||||
fn tenants_from_env_resolution() {
|
|
||||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
|
||||||
assert_eq!(
|
|
||||||
tenants_from_env(),
|
|
||||||
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
|
|
||||||
"unset → default"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::env::set_var("SCHEDULER_TENANT_IDS", "acme, globex ,,hello");
|
|
||||||
let out = tenants_from_env();
|
|
||||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
|
||||||
assert_eq!(
|
|
||||||
out,
|
|
||||||
vec!["acme", "globex", "hello"],
|
|
||||||
"splits + trims + drops empty"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::env::set_var("SCHEDULER_TENANT_IDS", "");
|
|
||||||
let out = tenants_from_env();
|
|
||||||
std::env::remove_var("SCHEDULER_TENANT_IDS");
|
|
||||||
assert_eq!(
|
|
||||||
out,
|
|
||||||
vec![DEFAULT_SCHEDULER_TENANT_ID.to_string()],
|
|
||||||
"empty → default"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the per-tenant database. Logs and returns `None` on failure
|
/// Resolve the per-tenant database. Logs and returns `None` on failure
|
||||||
/// so the loop in the caller can continue with other tenants.
|
/// so the loop in the caller can continue with other tenants.
|
||||||
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
|
async fn tenant_db(agent: &ComplianceAgent, tenant_id: &str) -> Option<Database> {
|
||||||
|
|||||||
@@ -66,8 +66,6 @@ impl TestServer {
|
|||||||
pentest_imap_tls: false,
|
pentest_imap_tls: false,
|
||||||
pentest_imap_username: None,
|
pentest_imap_username: None,
|
||||||
pentest_imap_password: None,
|
pentest_imap_password: None,
|
||||||
admin_api_token: None,
|
|
||||||
tenant_registry_url: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let agent = ComplianceAgent::new(config, db_pool);
|
let agent = ComplianceAgent::new(config, db_pool);
|
||||||
|
|||||||
@@ -63,24 +63,16 @@ struct Claims {
|
|||||||
|
|
||||||
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
|
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
|
||||||
|
|
||||||
/// Path prefixes that bypass JWT validation. The admin sub-router
|
|
||||||
/// (`/api/v1/admin/*`) has its own static-bearer middleware and must
|
|
||||||
/// not be routed through the customer-JWT path — a Keycloak token
|
|
||||||
/// always carries a single tenant_id and would semantically conflict
|
|
||||||
/// with cross-tenant admin operations.
|
|
||||||
const PUBLIC_PREFIXES: &[&str] = &["/api/v1/admin/"];
|
|
||||||
|
|
||||||
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS
|
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS
|
||||||
/// and attaches a `TenantContext` extension on success.
|
/// and attaches a `TenantContext` extension on success.
|
||||||
///
|
///
|
||||||
/// Skips validation for the health endpoint and any path under one of
|
/// Skips validation for the health endpoint.
|
||||||
/// the [`PUBLIC_PREFIXES`]. If `JwksState` is not present (Keycloak
|
/// If `JwksState` is not present (Keycloak not configured), requests
|
||||||
/// not configured), requests pass through and downstream code must
|
/// pass through and downstream code must handle the missing context.
|
||||||
/// handle the missing context.
|
|
||||||
pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response {
|
pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response {
|
||||||
let path = request.uri().path();
|
let path = request.uri().path();
|
||||||
|
|
||||||
if PUBLIC_ENDPOINTS.contains(&path) || PUBLIC_PREFIXES.iter().any(|p| path.starts_with(p)) {
|
if PUBLIC_ENDPOINTS.contains(&path) {
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,15 +37,6 @@ pub struct AgentConfig {
|
|||||||
pub pentest_imap_tls: bool,
|
pub pentest_imap_tls: bool,
|
||||||
pub pentest_imap_username: Option<String>,
|
pub pentest_imap_username: Option<String>,
|
||||||
pub pentest_imap_password: Option<SecretString>,
|
pub pentest_imap_password: Option<SecretString>,
|
||||||
/// Static bearer for the cross-tenant admin endpoints under
|
|
||||||
/// `/api/v1/admin/*`. When `None`, those endpoints are not
|
|
||||||
/// mounted at all (defense-in-depth: ops endpoints never reach
|
|
||||||
/// any auth path if no operator has explicitly opted in).
|
|
||||||
pub admin_api_token: Option<SecretString>,
|
|
||||||
/// Live tenant-registry URL the scheduler consults for the list
|
|
||||||
/// of tenants to iterate. When `None` or unreachable, scheduler
|
|
||||||
/// falls back to `SCHEDULER_TENANT_IDS` env (M7.2-C).
|
|
||||||
pub tenant_registry_url: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
//! Per-tenant API tokens used by `compliance-mcp` to authenticate MCP
|
|
||||||
//! HTTP requests on behalf of LLM clients (Claude Desktop, Cursor,
|
|
||||||
//! ChatGPT, etc.) that can't run a Keycloak OIDC flow.
|
|
||||||
//!
|
|
||||||
//! Tokens are opaque strings of the form `mcpt_<44 url-safe random
|
|
||||||
//! chars>`. The raw value is shown to the user exactly once at
|
|
||||||
//! creation; the database only ever sees the SHA-256 hash. Lookups go
|
|
||||||
//! through the cross-tenant `<prefix>__admin.mcp_tokens` collection
|
|
||||||
//! and return the `tenant_id` the MCP server should route to.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Persisted token metadata. `token_hash` is the SHA-256 hex of the
|
|
||||||
/// raw token; the raw token itself is never stored.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpToken {
|
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub id: Option<bson::oid::ObjectId>,
|
|
||||||
/// SHA-256 hex of the raw token. Unique index in the collection.
|
|
||||||
pub token_hash: String,
|
|
||||||
/// First 8 chars of the raw token — purely for UI display so users
|
|
||||||
/// can identify which token is which without re-issuing.
|
|
||||||
pub token_prefix: String,
|
|
||||||
/// Routes to `<db_prefix>_<tenant_id>` on MCP requests.
|
|
||||||
pub tenant_id: String,
|
|
||||||
/// User-given label, e.g. "Claude Desktop" or "Sharang's laptop".
|
|
||||||
pub name: String,
|
|
||||||
/// Keycloak `sub` of the user who created this token, for audit.
|
|
||||||
pub created_by: String,
|
|
||||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
|
||||||
pub last_used_at: Option<DateTime<Utc>>,
|
|
||||||
/// Soft-delete flag. A revoked token doc stays around for audit
|
|
||||||
/// but never authenticates.
|
|
||||||
#[serde(default)]
|
|
||||||
pub revoked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public projection of a token — never includes the hash.
|
|
||||||
/// Returned by `GET /api/v1/mcp-tokens`.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct McpTokenView {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
/// `mcpt_xxxx…` so the user can identify which row is which.
|
|
||||||
pub token_prefix: String,
|
|
||||||
pub created_by: String,
|
|
||||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
|
||||||
pub last_used_at: Option<DateTime<Utc>>,
|
|
||||||
pub revoked: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&McpToken> for McpTokenView {
|
|
||||||
fn from(t: &McpToken) -> Self {
|
|
||||||
Self {
|
|
||||||
id: t.id.map(|o| o.to_hex()).unwrap_or_default(),
|
|
||||||
name: t.name.clone(),
|
|
||||||
token_prefix: t.token_prefix.clone(),
|
|
||||||
created_by: t.created_by.clone(),
|
|
||||||
created_at: t.created_at,
|
|
||||||
last_used_at: t.last_used_at,
|
|
||||||
revoked: t.revoked,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ pub mod finding;
|
|||||||
pub mod graph;
|
pub mod graph;
|
||||||
pub mod issue;
|
pub mod issue;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod mcp_token;
|
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod pentest;
|
pub mod pentest;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
@@ -29,7 +28,6 @@ pub use graph::{
|
|||||||
};
|
};
|
||||||
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
pub use issue::{IssueStatus, TrackerIssue, TrackerType};
|
||||||
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
pub use mcp::{McpServerConfig, McpServerStatus, McpTransport};
|
||||||
pub use mcp_token::{McpToken, McpTokenView};
|
|
||||||
pub use notification::{CveNotification, NotificationSeverity, NotificationStatus};
|
pub use notification::{CveNotification, NotificationSeverity, NotificationStatus};
|
||||||
pub use pentest::{
|
pub use pentest::{
|
||||||
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider,
|
||||||
|
|||||||
@@ -9,16 +9,7 @@
|
|||||||
//! When Keycloak is not configured (dev convenience), the helper
|
//! When Keycloak is not configured (dev convenience), the helper
|
||||||
//! returns an unauthenticated builder — matching the agent's
|
//! returns an unauthenticated builder — matching the agent's
|
||||||
//! pass-through behavior in the same state.
|
//! pass-through behavior in the same state.
|
||||||
//!
|
|
||||||
//! **Token refresh**: KC access tokens are short-lived (5 min default
|
|
||||||
//! in the certifai realm). Before attaching, we decode the JWT's `exp`
|
|
||||||
//! claim and proactively refresh via the stored refresh_token if the
|
|
||||||
//! access token is expired or about to expire. The session is updated
|
|
||||||
//! with the new pair. If refresh fails, we send the (stale) token
|
|
||||||
//! anyway — the agent's 401 will surface to the UI, which can prompt
|
|
||||||
//! re-login.
|
|
||||||
|
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
||||||
use dioxus::prelude::ServerFnError;
|
use dioxus::prelude::ServerFnError;
|
||||||
use dioxus_fullstack::FullstackContext;
|
use dioxus_fullstack::FullstackContext;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
@@ -27,11 +18,6 @@ use super::auth::LOGGED_IN_USER_SESS_KEY;
|
|||||||
use super::server_state::ServerState;
|
use super::server_state::ServerState;
|
||||||
use super::user_state::UserStateInner;
|
use super::user_state::UserStateInner;
|
||||||
|
|
||||||
/// Seconds before the JWT's `exp` time at which we consider it stale
|
|
||||||
/// enough to refresh. Covers clock skew + the round-trip to the agent
|
|
||||||
/// so the token doesn't expire mid-flight.
|
|
||||||
const REFRESH_SKEW_SECS: i64 = 30;
|
|
||||||
|
|
||||||
/// Build a `RequestBuilder` for `<agent_api_url><path>` with the
|
/// Build a `RequestBuilder` for `<agent_api_url><path>` with the
|
||||||
/// session's access token attached. `path` should include a leading
|
/// session's access token attached. `path` should include a leading
|
||||||
/// `/`, e.g. `"/api/v1/repositories"`.
|
/// `/`, e.g. `"/api/v1/repositories"`.
|
||||||
@@ -52,9 +38,10 @@ pub async fn agent_get(path: &str) -> Result<reqwest::RequestBuilder, ServerFnEr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Attach the session's bearer token if Keycloak is configured AND the
|
/// Attach the session's bearer token if Keycloak is configured AND the
|
||||||
/// session has a logged-in user. Refresh the token proactively if it's
|
/// session has a logged-in user. Otherwise leave the request as-is.
|
||||||
/// expired or about to expire. Persists refreshed tokens back into the
|
///
|
||||||
/// session.
|
/// The Keycloak-disabled path mirrors the dashboard's `require_auth`
|
||||||
|
/// middleware, which short-circuits when `state.keycloak.is_none()`.
|
||||||
async fn attach_token(
|
async fn attach_token(
|
||||||
req: reqwest::RequestBuilder,
|
req: reqwest::RequestBuilder,
|
||||||
state: &ServerState,
|
state: &ServerState,
|
||||||
@@ -67,144 +54,8 @@ async fn attach_token(
|
|||||||
.get(LOGGED_IN_USER_SESS_KEY)
|
.get(LOGGED_IN_USER_SESS_KEY)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||||
let Some(mut user) = user else {
|
Ok(match user {
|
||||||
return Ok(req);
|
Some(u) => req.bearer_auth(u.access_token),
|
||||||
};
|
None => req,
|
||||||
|
})
|
||||||
if token_needs_refresh(&user.access_token) {
|
|
||||||
tracing::debug!("Access token expired or near-expiring; refreshing");
|
|
||||||
match refresh_tokens(state, &user.refresh_token).await {
|
|
||||||
Ok((new_access, new_refresh)) => {
|
|
||||||
user.access_token = new_access;
|
|
||||||
if let Some(rt) = new_refresh {
|
|
||||||
user.refresh_token = rt;
|
|
||||||
}
|
|
||||||
if let Err(e) = session.insert(LOGGED_IN_USER_SESS_KEY, &user).await {
|
|
||||||
tracing::warn!("Failed to persist refreshed tokens: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Token refresh failed: {e}; sending current token anyway");
|
|
||||||
// Fall through — the agent will 401 and the UI will
|
|
||||||
// prompt re-login. Better than failing the request at
|
|
||||||
// the dashboard layer with no helpful UX cue.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(req.bearer_auth(user.access_token))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the JWT's payload (no signature verification — the agent
|
|
||||||
/// does that) and check the `exp` claim. Treats malformed tokens as
|
|
||||||
/// expired so the refresh path runs.
|
|
||||||
fn token_needs_refresh(jwt: &str) -> bool {
|
|
||||||
let Some(payload_b64) = jwt.split('.').nth(1) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let Ok(bytes) = URL_SAFE_NO_PAD.decode(payload_b64) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct ExpClaim {
|
|
||||||
exp: i64,
|
|
||||||
}
|
|
||||||
let Ok(claims) = serde_json::from_slice::<ExpClaim>(&bytes) else {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
|
||||||
claims.exp - REFRESH_SKEW_SECS <= now
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exchange a refresh_token for a new access_token. Returns the new
|
|
||||||
/// access_token and (optionally) the new refresh_token KC issued.
|
|
||||||
/// KC may rotate refresh_tokens on use; we honor whatever it sends.
|
|
||||||
async fn refresh_tokens(
|
|
||||||
state: &ServerState,
|
|
||||||
refresh_token: &str,
|
|
||||||
) -> Result<(String, Option<String>), String> {
|
|
||||||
let kc = state
|
|
||||||
.keycloak
|
|
||||||
.ok_or_else(|| "Keycloak not configured".to_string())?;
|
|
||||||
if refresh_token.is_empty() {
|
|
||||||
return Err("no refresh_token in session".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct TokenResp {
|
|
||||||
access_token: String,
|
|
||||||
refresh_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = reqwest::Client::new()
|
|
||||||
.post(kc.token_endpoint())
|
|
||||||
.form(&[
|
|
||||||
("grant_type", "refresh_token"),
|
|
||||||
("client_id", kc.client_id.as_str()),
|
|
||||||
("refresh_token", refresh_token),
|
|
||||||
])
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("refresh request failed: {e}"))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
return Err(format!("refresh rejected ({status}): {body}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let r: TokenResp = resp
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("refresh response parse failed: {e}"))?;
|
|
||||||
Ok((r.access_token, r.refresh_token))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use base64::Engine;
|
|
||||||
|
|
||||||
/// Build a JWT-shaped string (header.payload.sig) with the given
|
|
||||||
/// payload. Signature is bogus — we never verify it locally.
|
|
||||||
fn make_jwt(payload: &serde_json::Value) -> String {
|
|
||||||
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(payload).unwrap());
|
|
||||||
format!("hdr.{payload_b64}.sig")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_needs_refresh_true_when_expired() {
|
|
||||||
let exp = chrono::Utc::now().timestamp() - 60;
|
|
||||||
let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
|
|
||||||
assert!(token_needs_refresh(&jwt));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_needs_refresh_true_within_skew_window() {
|
|
||||||
// 10 seconds left; less than the 30s skew → must refresh.
|
|
||||||
let exp = chrono::Utc::now().timestamp() + 10;
|
|
||||||
let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
|
|
||||||
assert!(token_needs_refresh(&jwt));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_needs_refresh_false_with_plenty_of_life() {
|
|
||||||
let exp = chrono::Utc::now().timestamp() + 600;
|
|
||||||
let jwt = make_jwt(&serde_json::json!({ "exp": exp }));
|
|
||||||
assert!(!token_needs_refresh(&jwt));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_needs_refresh_true_on_malformed_jwt() {
|
|
||||||
assert!(token_needs_refresh(""));
|
|
||||||
assert!(token_needs_refresh("not.a.jwt"));
|
|
||||||
assert!(token_needs_refresh("only-one-segment"));
|
|
||||||
assert!(token_needs_refresh("hdr.not-base64!.sig"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_needs_refresh_true_when_exp_missing() {
|
|
||||||
let jwt = make_jwt(&serde_json::json!({ "sub": "abc" }));
|
|
||||||
assert!(token_needs_refresh(&jwt));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
compliance-core = { workspace = true, features = ["mongodb", "axum"] }
|
compliance-core = { workspace = true, features = ["mongodb"] }
|
||||||
rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }
|
rmcp = { version = "0.16", features = ["server", "macros", "transport-io", "transport-streamable-http-server"] }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -19,6 +19,3 @@ bson = { version = "2", features = ["chrono-0_4"] }
|
|||||||
schemars = "1.0"
|
schemars = "1.0"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
sha2 = { workspace = true }
|
|
||||||
hex = { workspace = true }
|
|
||||||
dashmap = { workspace = true }
|
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
//! Bearer-token authentication for incoming MCP HTTP requests.
|
|
||||||
//!
|
|
||||||
//! LLM clients (Claude Desktop / Cursor / ChatGPT / etc.) can't run
|
|
||||||
//! Keycloak OIDC, so the MCP server uses opaque static tokens minted
|
|
||||||
//! per-tenant via the agent's `POST /api/v1/mcp-tokens` endpoint.
|
|
||||||
//!
|
|
||||||
//! Flow per request:
|
|
||||||
//! 1. Extract `Authorization: Bearer <token>`. Missing → 401.
|
|
||||||
//! 2. SHA-256 hash the token.
|
|
||||||
//! 3. Look up the hash in `<prefix>__admin.mcp_tokens`. Missing or
|
|
||||||
//! revoked → 401.
|
|
||||||
//! 4. Fire-and-forget update of `last_used_at` so the dashboard can
|
|
||||||
//! show staleness without blocking the handler.
|
|
||||||
//! 5. Stash the tenant_id in [`TENANT_ID`] (a `tokio::task_local`) so
|
|
||||||
//! the MCP tool handlers can read it without modifying rmcp's
|
|
||||||
//! handler signatures.
|
|
||||||
//!
|
|
||||||
//! The `task_local` is scoped around the inner service call via
|
|
||||||
//! [`bearer_auth`], so every handler invoked downstream sees the
|
|
||||||
//! tenant_id without us having to thread it through the macro-
|
|
||||||
//! generated tool router.
|
|
||||||
|
|
||||||
use axum::body::Body;
|
|
||||||
use axum::extract::{Request, State};
|
|
||||||
use axum::http::StatusCode;
|
|
||||||
use axum::middleware::Next;
|
|
||||||
use axum::response::{IntoResponse, Response};
|
|
||||||
use mongodb::bson::doc;
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::database::DatabasePool;
|
|
||||||
|
|
||||||
tokio::task_local! {
|
|
||||||
/// Tenant id resolved from the bearer for this request. Set by
|
|
||||||
/// [`bearer_auth`] before the inner service runs; read by the
|
|
||||||
/// MCP tool handlers via [`current_tenant_id`].
|
|
||||||
pub static TENANT_ID: String;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mongo collection name in `<prefix>__admin`.
|
|
||||||
const COLLECTION: &str = "mcp_tokens";
|
|
||||||
|
|
||||||
/// Returns the tenant_id set by the auth middleware. `None` outside a
|
|
||||||
/// request scope (e.g. unit tests that bypass the middleware).
|
|
||||||
pub fn current_tenant_id() -> Option<String> {
|
|
||||||
TENANT_ID.try_with(|s| s.clone()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Axum middleware: validate bearer → set [`TENANT_ID`] → call inner.
|
|
||||||
pub async fn bearer_auth(
|
|
||||||
State(pool): State<DatabasePool>,
|
|
||||||
request: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Response {
|
|
||||||
let Some(token) = extract_bearer(&request) else {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Missing bearer token").into_response();
|
|
||||||
};
|
|
||||||
if !token.starts_with("mcpt_") {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Invalid token format").into_response();
|
|
||||||
}
|
|
||||||
let token_hash = sha256_hex(&token);
|
|
||||||
|
|
||||||
let col = pool.admin_db().collection::<TokenLookup>(COLLECTION);
|
|
||||||
let found = match col
|
|
||||||
.find_one(doc! { "token_hash": &token_hash, "revoked": false })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(Some(t)) => t,
|
|
||||||
Ok(None) => {
|
|
||||||
return (StatusCode::UNAUTHORIZED, "Invalid or revoked token").into_response();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("MCP token lookup failed: {e}");
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Token lookup error").into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fire-and-forget last_used_at update — never block the handler.
|
|
||||||
let col2 = pool.admin_db().collection::<TokenLookup>(COLLECTION);
|
|
||||||
let hash_for_update = token_hash.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = col2
|
|
||||||
.update_one(
|
|
||||||
doc! { "token_hash": &hash_for_update },
|
|
||||||
doc! { "$set": { "last_used_at": mongodb::bson::DateTime::now() } },
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let tenant_id = found.tenant_id;
|
|
||||||
let inner = next.run(request);
|
|
||||||
TENANT_ID.scope(tenant_id, inner).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bare-bones projection — we don't need the whole `McpToken` here,
|
|
||||||
/// just enough to route and confirm validity.
|
|
||||||
#[derive(serde::Deserialize)]
|
|
||||||
struct TokenLookup {
|
|
||||||
tenant_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_bearer(req: &Request<Body>) -> Option<String> {
|
|
||||||
req.headers()
|
|
||||||
.get(axum::http::header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|s| s.strip_prefix("Bearer "))
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
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 sha256_known_value() {
|
|
||||||
// python -c 'import hashlib; print(hashlib.sha256(b"mcpt_known").hexdigest())'
|
|
||||||
assert_eq!(
|
|
||||||
sha256_hex("mcpt_known"),
|
|
||||||
"27cf6cf678a44244106863c1c031be8e57b84c2b3019d742f755f8e7afa75dfd"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +1,19 @@
|
|||||||
//! Per-tenant Mongo broker for the MCP server.
|
use mongodb::{Client, Collection};
|
||||||
//!
|
|
||||||
//! 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::*;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Database {
|
pub struct Database {
|
||||||
inner: mongodb::Database,
|
inner: mongodb::Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub(crate) fn new(inner: mongodb::Database) -> Self {
|
pub async fn connect(uri: &str, db_name: &str) -> Result<Self, mongodb::error::Error> {
|
||||||
Self { inner }
|
let client = Client::with_uri_str(uri).await?;
|
||||||
|
let db = client.database(db_name);
|
||||||
|
db.run_command(mongodb::bson::doc! { "ping": 1 }).await?;
|
||||||
|
tracing::info!("MCP server connected to MongoDB '{db_name}'");
|
||||||
|
Ok(Self { inner: db })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn findings(&self) -> Collection<Finding> {
|
pub fn findings(&self) -> Collection<Finding> {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
mod auth;
|
|
||||||
mod database;
|
mod database;
|
||||||
mod server;
|
mod server;
|
||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use database::DatabasePool;
|
use database::Database;
|
||||||
use rmcp::transport::{
|
use rmcp::transport::{
|
||||||
streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig,
|
streamable_http_server::session::local::LocalSessionManager, StreamableHttpServerConfig,
|
||||||
StreamableHttpService,
|
StreamableHttpService,
|
||||||
@@ -25,60 +24,36 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let mongo_uri =
|
let mongo_uri =
|
||||||
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
|
std::env::var("MONGODB_URI").unwrap_or_else(|_| "mongodb://localhost:27017".to_string());
|
||||||
// MONGODB_DATABASE is reused as the per-tenant DB-name prefix —
|
let db_name =
|
||||||
// same convention as the agent so `<prefix>__admin.mcp_tokens`
|
|
||||||
// and `<prefix>_<tenant_id>` line up across services.
|
|
||||||
let db_prefix =
|
|
||||||
std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "compliance_scanner".to_string());
|
std::env::var("MONGODB_DATABASE").unwrap_or_else(|_| "compliance_scanner".to_string());
|
||||||
|
|
||||||
let pool = DatabasePool::connect(&mongo_uri, &db_prefix).await?;
|
let db = Database::connect(&mongo_uri, &db_name).await?;
|
||||||
|
|
||||||
// HTTP transport: bind a small axum router with bearer-auth in
|
// If MCP_PORT is set, run as Streamable HTTP server; otherwise use stdio.
|
||||||
// front of the rmcp service. `/health` stays public for orca's
|
|
||||||
// container probe.
|
|
||||||
if let Ok(port_str) = std::env::var("MCP_PORT") {
|
if let Ok(port_str) = std::env::var("MCP_PORT") {
|
||||||
let port: u16 = port_str.parse()?;
|
let port: u16 = port_str.parse()?;
|
||||||
tracing::info!("Starting MCP server on HTTP port {port}");
|
tracing::info!("Starting MCP server on HTTP port {port}");
|
||||||
|
|
||||||
let pool_for_factory = pool.clone();
|
let db_clone = db.clone();
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || Ok(ComplianceMcpServer::new(pool_for_factory.clone())),
|
move || Ok(ComplianceMcpServer::new(db_clone.clone())),
|
||||||
Arc::new(LocalSessionManager::default()),
|
Arc::new(LocalSessionManager::default()),
|
||||||
StreamableHttpServerConfig::default(),
|
StreamableHttpServerConfig::default(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let router = axum::Router::new()
|
let router = axum::Router::new()
|
||||||
.route("/health", axum::routing::get(|| async { "ok" }))
|
.route("/health", axum::routing::get(|| async { "ok" }))
|
||||||
.nest_service(
|
.nest_service("/mcp", service);
|
||||||
"/mcp",
|
|
||||||
axum::Router::new().fallback_service(service).layer(
|
|
||||||
axum::middleware::from_fn_with_state(pool.clone(), auth::bearer_auth),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
|
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
|
||||||
tracing::info!("MCP HTTP server listening on 0.0.0.0:{port}");
|
tracing::info!("MCP HTTP server listening on 0.0.0.0:{port}");
|
||||||
axum::serve(listener, router).await?;
|
axum::serve(listener, router).await?;
|
||||||
} else {
|
} else {
|
||||||
// stdio transport — used when run as a local MCP server next
|
|
||||||
// to the LLM client. There's no HTTP layer to do bearer auth,
|
|
||||||
// so we synthesize a tenant_id from STDIO_TENANT_ID for local
|
|
||||||
// development. NEVER use this in production.
|
|
||||||
tracing::info!("Starting MCP server on stdio");
|
tracing::info!("Starting MCP server on stdio");
|
||||||
let synth_tenant = std::env::var("STDIO_TENANT_ID").unwrap_or_else(|_| "dev".to_string());
|
let server = ComplianceMcpServer::new(db);
|
||||||
tracing::warn!(
|
|
||||||
tenant_id = %synth_tenant,
|
|
||||||
"stdio transport — using synthetic tenant id; DO NOT use in production"
|
|
||||||
);
|
|
||||||
let server = ComplianceMcpServer::new(pool);
|
|
||||||
let transport = rmcp::transport::stdio();
|
let transport = rmcp::transport::stdio();
|
||||||
use rmcp::ServiceExt;
|
use rmcp::ServiceExt;
|
||||||
auth::TENANT_ID
|
|
||||||
.scope(synth_tenant, async {
|
|
||||||
let handle = server.serve(transport).await?;
|
let handle = server.serve(transport).await?;
|
||||||
handle.waiting().await?;
|
handle.waiting().await?;
|
||||||
Ok::<_, Box<dyn std::error::Error>>(())
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -2,37 +2,20 @@ use rmcp::{
|
|||||||
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
handler::server::wrapper::Parameters, model::*, tool, tool_handler, tool_router, ServerHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::current_tenant_id;
|
use crate::database::Database;
|
||||||
use crate::database::{Database, DatabasePool};
|
|
||||||
use crate::tools::{dast, findings, pentest, sbom};
|
use crate::tools::{dast, findings, pentest, sbom};
|
||||||
|
|
||||||
pub struct ComplianceMcpServer {
|
pub struct ComplianceMcpServer {
|
||||||
pool: DatabasePool,
|
db: Database,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
tool_router: rmcp::handler::server::router::tool::ToolRouter<Self>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ComplianceMcpServer {
|
|
||||||
/// Resolve the per-tenant `Database` from the bearer-set
|
|
||||||
/// `task_local`. Every tool handler calls this; missing context
|
|
||||||
/// surfaces as `internal_error` because it means the auth
|
|
||||||
/// middleware was misconfigured (handler ran without scope).
|
|
||||||
fn tenant_db(&self) -> Result<Database, rmcp::ErrorData> {
|
|
||||||
let tenant_id = current_tenant_id().ok_or_else(|| {
|
|
||||||
rmcp::ErrorData::internal_error(
|
|
||||||
"no tenant context — bearer middleware not in chain".to_string(),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(self.pool.for_tenant_id(&tenant_id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool_router]
|
#[tool_router]
|
||||||
impl ComplianceMcpServer {
|
impl ComplianceMcpServer {
|
||||||
pub fn new(pool: DatabasePool) -> Self {
|
pub fn new(db: Database) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
db,
|
||||||
tool_router: Self::tool_router(),
|
tool_router: Self::tool_router(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,8 +29,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<findings::ListFindingsParams>,
|
Parameters(params): Parameters<findings::ListFindingsParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
findings::list_findings(&self.db, params).await
|
||||||
findings::list_findings(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Get a single finding by its ID")]
|
#[tool(description = "Get a single finding by its ID")]
|
||||||
@@ -55,8 +37,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<findings::GetFindingParams>,
|
Parameters(params): Parameters<findings::GetFindingParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
findings::get_finding(&self.db, params).await
|
||||||
findings::get_finding(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Get a summary of findings counts grouped by severity and status")]
|
#[tool(description = "Get a summary of findings counts grouped by severity and status")]
|
||||||
@@ -64,8 +45,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<findings::FindingsSummaryParams>,
|
Parameters(params): Parameters<findings::FindingsSummaryParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
findings::findings_summary(&self.db, params).await
|
||||||
findings::findings_summary(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SBOM ──────────────────────────────────────────────
|
// ── SBOM ──────────────────────────────────────────────
|
||||||
@@ -77,8 +57,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<sbom::ListSbomPackagesParams>,
|
Parameters(params): Parameters<sbom::ListSbomPackagesParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
sbom::list_sbom_packages(&self.db, params).await
|
||||||
sbom::list_sbom_packages(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
@@ -88,8 +67,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<sbom::SbomVulnReportParams>,
|
Parameters(params): Parameters<sbom::SbomVulnReportParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
sbom::sbom_vuln_report(&self.db, params).await
|
||||||
sbom::sbom_vuln_report(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DAST ──────────────────────────────────────────────
|
// ── DAST ──────────────────────────────────────────────
|
||||||
@@ -101,8 +79,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<dast::ListDastFindingsParams>,
|
Parameters(params): Parameters<dast::ListDastFindingsParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
dast::list_dast_findings(&self.db, params).await
|
||||||
dast::list_dast_findings(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Get a summary of recent DAST scan runs and finding counts")]
|
#[tool(description = "Get a summary of recent DAST scan runs and finding counts")]
|
||||||
@@ -110,8 +87,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<dast::DastScanSummaryParams>,
|
Parameters(params): Parameters<dast::DastScanSummaryParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
dast::dast_scan_summary(&self.db, params).await
|
||||||
dast::dast_scan_summary(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pentest ─────────────────────────────────────────────
|
// ── Pentest ─────────────────────────────────────────────
|
||||||
@@ -123,8 +99,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<pentest::ListPentestSessionsParams>,
|
Parameters(params): Parameters<pentest::ListPentestSessionsParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
pentest::list_pentest_sessions(&self.db, params).await
|
||||||
pentest::list_pentest_sessions(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Get a single AI pentest session by its ID")]
|
#[tool(description = "Get a single AI pentest session by its ID")]
|
||||||
@@ -132,8 +107,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<pentest::GetPentestSessionParams>,
|
Parameters(params): Parameters<pentest::GetPentestSessionParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
pentest::get_pentest_session(&self.db, params).await
|
||||||
pentest::get_pentest_session(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
@@ -143,8 +117,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<pentest::GetAttackChainParams>,
|
Parameters(params): Parameters<pentest::GetAttackChainParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
pentest::get_attack_chain(&self.db, params).await
|
||||||
pentest::get_attack_chain(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Get chat messages from a pentest session")]
|
#[tool(description = "Get chat messages from a pentest session")]
|
||||||
@@ -152,8 +125,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<pentest::GetPentestMessagesParams>,
|
Parameters(params): Parameters<pentest::GetPentestMessagesParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
pentest::get_pentest_messages(&self.db, params).await
|
||||||
pentest::get_pentest_messages(&db, params).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
@@ -163,8 +135,7 @@ impl ComplianceMcpServer {
|
|||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<pentest::PentestStatsParams>,
|
Parameters(params): Parameters<pentest::PentestStatsParams>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let db = self.tenant_db()?;
|
pentest::pentest_stats(&self.db, params).await
|
||||||
pentest::pentest_stats(&db, params).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +149,7 @@ impl ServerHandler for ComplianceMcpServer {
|
|||||||
.build(),
|
.build(),
|
||||||
server_info: Implementation::from_build_env(),
|
server_info: Implementation::from_build_env(),
|
||||||
instructions: Some(
|
instructions: Some(
|
||||||
"Compliance Scanner MCP server. Query security findings, SBOM data, DAST results, and AI pentest sessions for your tenant."
|
"Compliance Scanner MCP server. Query security findings, SBOM data, DAST results, and AI pentest sessions."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user