feat(m7.3): cross-tenant admin HTTP endpoints
CI / Check (pull_request) Successful in 8m4s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Check (pull_request) Successful in 8m4s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,15 @@ pub struct AgentConfig {
|
||||
pub pentest_imap_tls: bool,
|
||||
pub pentest_imap_username: Option<String>,
|
||||
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)]
|
||||
|
||||
Reference in New Issue
Block a user