From e20e7f1c6eb9badefc0176c7c572091fc16d9e94 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:02:37 +0200 Subject: [PATCH] feat(m7.3): cross-tenant admin HTTP endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two cross-tenant operator endpoints on top of the M7.2-D DatabasePool primitives: - GET /api/v1/admin/tenants → list tenant DBs - DELETE /api/v1/admin/tenants/{tenant_id} → drop (GDPR delete) Auth is a static bearer (ADMIN_API_TOKEN env), explicitly NOT a Keycloak JWT — the whole point is to operate across tenants and a customer JWT always carries a single tenant_id, which would be a semantic conflict. Comparison is constant-time to avoid byte-level timing probes. Design - ADMIN_API_TOKEN env on the agent. When unset, the admin routes aren't mounted at all (404 rather than 401). An operator who hasn't opted in can't fingerprint the surface. - Admin sub-router is built in start_api_server when the token is configured, then merged into the main router with its own require_admin_token middleware. - compliance-core::auth gains a PUBLIC_PREFIXES list. Paths under /api/v1/admin/ bypass require_jwt_auth so the customer JWT path and the admin token path never collide. - require_tenant_status passes through naturally — admin requests carry no TenantContext. Files - compliance-core/src/auth.rs — PUBLIC_PREFIXES + prefix-aware skip. - compliance-core/src/config.rs — admin_api_token + tenant_registry_url fields on AgentConfig. tenant_registry_url is added now so the scheduler→registry PR doesn't have to bump the config shape again. - compliance-agent/src/config.rs — env wiring for both. - compliance-agent/src/api/handlers/admin.rs (new) — list_tenant_dbs, drop_tenant_db, require_admin_token middleware, tokens_eq helper with a small test. - compliance-agent/src/api/server.rs — conditional admin sub-router + merge. - Test harness fixtures updated for the two new config fields. Test plan - cargo fmt --all clean - cargo clippy --workspace --exclude compliance-dashboard -- -D warnings clean - cargo test -p compliance-core --lib — 7 pass - cargo test -p compliance-agent --lib — 229 pass (+1 new for tokens_eq) Production - Set ADMIN_API_TOKEN in orca-infra (per-secret, NOT committed) when ready to expose these endpoints. Without the env, the routes literally don't exist on the binary. - Long-term: replace the static bearer with a dedicated admin realm in Keycloak. Token rotation is just an env change + restart for now; revocation responsiveness is zero. Co-Authored-By: Claude Opus 4.7 --- compliance-agent/src/api/handlers/admin.rs | 115 +++++++++++++++++++++ compliance-agent/src/api/handlers/mod.rs | 1 + compliance-agent/src/api/server.rs | 25 ++++- compliance-agent/src/config.rs | 2 + compliance-agent/src/pentest/cleanup.rs | 2 + compliance-agent/tests/common/mod.rs | 2 + compliance-core/src/auth.rs | 16 ++- compliance-core/src/config.rs | 9 ++ 8 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 compliance-agent/src/api/handlers/admin.rs diff --git a/compliance-agent/src/api/handlers/admin.rs b/compliance-agent/src/api/handlers/admin.rs new file mode 100644 index 0000000..5a690f7 --- /dev/null +++ b/compliance-agent/src/api/handlers/admin.rs @@ -0,0 +1,115 @@ +//! 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, +} + +#[tracing::instrument(skip_all)] +pub async fn list_tenant_dbs( + Extension(agent): AgentExt, +) -> Result, 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, +) -> Result, 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("", "")); + } +} diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index ea1d74d..5656542 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; pub mod chat; pub mod dast; pub mod dto; diff --git a/compliance-agent/src/api/server.rs b/compliance-agent/src/api/server.rs index e8a3f82..b56cef8 100644 --- a/compliance-agent/src/api/server.rs +++ b/compliance-agent/src/api/server.rs @@ -4,7 +4,8 @@ use axum::extract::Request; use axum::http::HeaderValue; use axum::middleware::Next; use axum::response::Response; -use axum::{middleware, Extension}; +use axum::routing::{delete, get}; +use axum::{middleware, Extension, Router}; use tokio::sync::RwLock; use tower_http::cors::CorsLayer; use tower_http::set_header::SetResponseHeaderLayer; @@ -14,6 +15,7 @@ use compliance_core::auth::{require_jwt_auth, require_tenant_status, JwksState}; use compliance_core::{TenantContext, TenantStatus}; use crate::agent::ComplianceAgent; +use crate::api::handlers; use crate::api::routes; use crate::error::AgentError; @@ -50,7 +52,28 @@ pub async fn inject_dev_tenant(mut request: Request, next: Next) -> Response { } 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() + .merge(admin_router) .layer(Extension(Arc::new(agent.clone()))) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index b58ebab..d8dd624 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -59,5 +59,7 @@ pub fn load_config() -> Result { .unwrap_or(true), pentest_imap_username: env_var_opt("PENTEST_IMAP_USERNAME"), 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"), }) } diff --git a/compliance-agent/src/pentest/cleanup.rs b/compliance-agent/src/pentest/cleanup.rs index b15f8bc..77ef537 100644 --- a/compliance-agent/src/pentest/cleanup.rs +++ b/compliance-agent/src/pentest/cleanup.rs @@ -339,6 +339,8 @@ mod tests { pentest_imap_tls: true, pentest_imap_username: None, pentest_imap_password: None, + admin_api_token: None, + tenant_registry_url: None, } } diff --git a/compliance-agent/tests/common/mod.rs b/compliance-agent/tests/common/mod.rs index 5857203..68020e3 100644 --- a/compliance-agent/tests/common/mod.rs +++ b/compliance-agent/tests/common/mod.rs @@ -66,6 +66,8 @@ impl TestServer { pentest_imap_tls: false, pentest_imap_username: None, pentest_imap_password: None, + admin_api_token: None, + tenant_registry_url: None, }; let agent = ComplianceAgent::new(config, db_pool); diff --git a/compliance-core/src/auth.rs b/compliance-core/src/auth.rs index 4b422e7..849503f 100644 --- a/compliance-core/src/auth.rs +++ b/compliance-core/src/auth.rs @@ -63,16 +63,24 @@ struct Claims { 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 /// and attaches a `TenantContext` extension on success. /// -/// Skips validation for the health endpoint. -/// If `JwksState` is not present (Keycloak not configured), requests -/// pass through and downstream code must handle the missing context. +/// Skips validation for the health endpoint and any path under one of +/// the [`PUBLIC_PREFIXES`]. If `JwksState` is not present (Keycloak +/// not configured), requests pass through and downstream code must +/// handle the missing context. pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response { let path = request.uri().path(); - if PUBLIC_ENDPOINTS.contains(&path) { + if PUBLIC_ENDPOINTS.contains(&path) || PUBLIC_PREFIXES.iter().any(|p| path.starts_with(p)) { return next.run(request).await; } diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index 9f29915..e99d3fd 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -37,6 +37,15 @@ pub struct AgentConfig { pub pentest_imap_tls: bool, pub pentest_imap_username: Option, pub pentest_imap_password: Option, + /// 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, + /// 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, } #[derive(Clone, Debug, Serialize, Deserialize)]