From 05c01ea547d08312a57f2310e1be7cbfa6548372 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 20 May 2026 10:10:42 +0200 Subject: [PATCH 1/2] feat(m7.1): wire tenant claims, status enforcement, and db scoping helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the platform-wide multi-tenancy infrastructure on top of the existing Keycloak signature validation. JWTs now carry tenant_id, tenant_slug, org_roles, products, plan, and tenant_status; the middleware decodes them into a TenantContext and attaches it to the request extensions. A TenantCtx Axum extractor exposes the context to handlers, and a tenant_status middleware enforces the §5c lifecycle (frozen tenants are 402 on writes; archived tenants are 410 on every method). A db::tenant_filter helper in compliance-core gives every future collection a single grep-able pattern for tenant-scoped queries. Per-collection wiring (adding tenant_id to each model + threading the filter through every find/update/delete call) lands in a follow-up. Tests: 6 inline unit tests for claims→context mapping, 2 for the extractor, 6 integration tests for status middleware, 3 for db filter. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 1 + compliance-agent/Cargo.toml | 1 + compliance-agent/src/api/auth_middleware.rs | 212 +++++++++++++++++- compliance-agent/src/api/mod.rs | 2 + compliance-agent/src/api/tenant_ctx.rs | 94 ++++++++ .../tests/tenant_status_middleware.rs | 123 ++++++++++ compliance-core/src/db.rs | 75 +++++++ compliance-core/src/lib.rs | 3 + compliance-core/src/tenant.rs | 165 ++++++++++++++ 9 files changed, 666 insertions(+), 10 deletions(-) create mode 100644 compliance-agent/src/api/tenant_ctx.rs create mode 100644 compliance-agent/tests/tenant_status_middleware.rs create mode 100644 compliance-core/src/db.rs create mode 100644 compliance-core/src/tenant.rs diff --git a/Cargo.lock b/Cargo.lock index 0714ef6..ef7994e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,7 @@ dependencies = [ "tokio-cron-scheduler", "tokio-stream", "tokio-tungstenite 0.26.2", + "tower", "tower-http", "tracing", "tracing-subscriber", diff --git a/compliance-agent/Cargo.toml b/compliance-agent/Cargo.toml index e0a129f..5f43059 100644 --- a/compliance-agent/Cargo.toml +++ b/compliance-agent/Cargo.toml @@ -52,4 +52,5 @@ mongodb = { workspace = true } uuid = { workspace = true } secrecy = { workspace = true } axum = "0.8" +tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.6", features = ["cors"] } diff --git a/compliance-agent/src/api/auth_middleware.rs b/compliance-agent/src/api/auth_middleware.rs index 90e3a49..e785c42 100644 --- a/compliance-agent/src/api/auth_middleware.rs +++ b/compliance-agent/src/api/auth_middleware.rs @@ -1,10 +1,29 @@ +//! M7.1 — JWT validation + tenant context propagation. +//! +//! `require_jwt_auth` validates a Bearer JWT against Keycloak's JWKS and +//! attaches a `TenantContext` to the request extensions. Downstream +//! middleware (`require_tenant_status`) and Axum extractors (`TenantCtx`) +//! read it from there. +//! +//! Skipped paths: +//! * `/api/v1/health` — Kubernetes liveness; never authenticated. +//! +//! Failure modes: +//! * No `JwksState` extension → pass-through (single-tenant dev mode). +//! * Missing / malformed Bearer header → 401. +//! * Signature / expiry invalid → 401. +//! * Claims present but tenant_id missing → 401 (treated as a malformed +//! token; the realm must always issue tenant_id). + use std::sync::Arc; use axum::{ extract::Request, + http::Method, middleware::Next, response::{IntoResponse, Response}, }; +use compliance_core::{OrgRole, TenantContext, TenantStatus}; use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation}; use reqwest::StatusCode; use serde::Deserialize; @@ -17,20 +36,39 @@ pub struct JwksState { pub jwks_url: String, } +/// Raw shape of the JWT payload — matches the breakpilot-dev realm's +/// protocol-mapper output. Missing fields default to "" / empty so a +/// realm that hasn't been fully wired yet still validates. #[derive(Debug, Deserialize)] struct Claims { - #[allow(dead_code)] sub: String, + #[serde(default)] + name: Option, + #[serde(default)] + preferred_username: Option, + #[serde(default)] + tenant_id: String, + #[serde(default)] + tenant_slug: String, + #[serde(default)] + org_roles: Vec, + #[serde(default)] + products: Vec, + #[serde(default)] + plan: String, + #[serde(default)] + tenant_status: Option, } const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"]; -/// 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. /// -/// Skips validation for health check endpoints. -/// If `JwksState` is not present as an extension (keycloak not configured), -/// all requests pass through. -pub async fn require_jwt_auth(request: Request, next: Next) -> Response { +/// 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. +pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response { let path = request.uri().path(); if PUBLIC_ENDPOINTS.contains(&path) { @@ -53,7 +91,10 @@ pub async fn require_jwt_auth(request: Request, next: Next) -> Response { }; match validate_token(token, &jwks_state).await { - Ok(()) => next.run(request).await, + Ok(ctx) => { + request.extensions_mut().insert(ctx); + next.run(request).await + } Err(e) => { tracing::warn!("JWT validation failed: {e}"); (StatusCode::UNAUTHORIZED, "Invalid token").into_response() @@ -61,7 +102,47 @@ pub async fn require_jwt_auth(request: Request, next: Next) -> Response { } } -async fn validate_token(token: &str, state: &JwksState) -> Result<(), String> { +/// Middleware that enforces the M7.1 `tenant_status` contract. +/// +/// * `Active` / `Trial` / `Demo` — pass through. +/// * `Frozen` — read-only after cancel / non-payment. Writes return 402. +/// * `Archived` — data-retention window closed. Every request returns 410. +/// +/// Pass-through when no `TenantContext` is present (single-tenant dev or +/// the upstream JWT middleware ran without `JwksState`). +pub async fn require_tenant_status(request: Request, next: Next) -> Response { + let ctx = match request.extensions().get::() { + Some(c) => c.clone(), + None => return next.run(request).await, + }; + + if ctx.status.is_archived() { + return ( + StatusCode::GONE, + "Tenant archived — data retention window closed", + ) + .into_response(); + } + + if ctx.status.is_frozen() && is_write(request.method()) { + return ( + StatusCode::PAYMENT_REQUIRED, + "Tenant frozen — read-only. Re-activate to resume writes.", + ) + .into_response(); + } + + next.run(request).await +} + +/// Treat anything other than GET/HEAD/OPTIONS as a write. Good enough for +/// REST. The few exceptions (e.g. read-side POSTs) can opt out at the +/// handler level once we have them. +fn is_write(m: &Method) -> bool { + !matches!(m, &Method::GET | &Method::HEAD | &Method::OPTIONS) +} + +async fn validate_token(token: &str, state: &JwksState) -> Result { let header = decode_header(token).map_err(|e| format!("failed to decode JWT header: {e}"))?; let kid = header @@ -83,10 +164,37 @@ async fn validate_token(token: &str, state: &JwksState) -> Result<(), String> { validation.validate_exp = true; validation.validate_aud = false; - decode::(token, &decoding_key, &validation) + let data = decode::(token, &decoding_key, &validation) .map_err(|e| format!("token validation failed: {e}"))?; - Ok(()) + claims_to_context(data.claims) +} + +/// Map the decoded JWT payload into the platform-wide `TenantContext`. +/// Pulled out for unit testing — no I/O. +fn claims_to_context(c: Claims) -> Result { + if c.tenant_id.is_empty() { + return Err("JWT is missing tenant_id claim".to_string()); + } + + let status = c.tenant_status.unwrap_or_else(|| { + tracing::warn!( + "JWT missing tenant_status claim for tenant {} — defaulting to Trial", + c.tenant_id + ); + TenantStatus::Trial + }); + + Ok(TenantContext { + tenant_id: c.tenant_id, + tenant_slug: c.tenant_slug, + org_roles: c.org_roles.iter().map(|r| OrgRole::parse(r)).collect(), + products: c.products, + plan: c.plan, + status, + user_id: c.sub, + user_name: c.name.or(c.preferred_username), + }) } async fn fetch_or_get_jwks(state: &JwksState) -> Result { @@ -111,3 +219,87 @@ async fn fetch_or_get_jwks(state: &JwksState) -> Result { Ok(jwks) } + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + + fn base_claims() -> Claims { + Claims { + sub: "user-123".to_string(), + name: Some("Alice Acme".to_string()), + preferred_username: None, + tenant_id: "00000000-0000-0000-0000-000000000001".to_string(), + tenant_slug: "acme".to_string(), + org_roles: vec!["IT_ADMIN".to_string()], + products: vec!["compliance".to_string()], + plan: "professional".to_string(), + tenant_status: Some(TenantStatus::Active), + } + } + + #[test] + fn claims_to_context_happy_path() { + let ctx = claims_to_context(base_claims()).expect("should map"); + assert_eq!(ctx.tenant_id, "00000000-0000-0000-0000-000000000001"); + assert_eq!(ctx.tenant_slug, "acme"); + assert_eq!(ctx.org_roles, vec![OrgRole::ItAdmin]); + assert_eq!(ctx.products, vec!["compliance"]); + assert_eq!(ctx.plan, "professional"); + assert_eq!(ctx.status, TenantStatus::Active); + assert_eq!(ctx.user_id, "user-123"); + assert_eq!(ctx.user_name.as_deref(), Some("Alice Acme")); + } + + #[test] + fn claims_to_context_rejects_missing_tenant_id() { + let mut c = base_claims(); + c.tenant_id = "".to_string(); + let err = claims_to_context(c).expect_err("should reject"); + assert!(err.contains("tenant_id")); + } + + #[test] + fn claims_to_context_defaults_status_when_missing() { + let mut c = base_claims(); + c.tenant_status = None; + let ctx = claims_to_context(c).expect("should map"); + assert_eq!(ctx.status, TenantStatus::Trial); + } + + #[test] + fn claims_to_context_falls_back_to_preferred_username() { + let mut c = base_claims(); + c.name = None; + c.preferred_username = Some("alice@acme.dev".to_string()); + let ctx = claims_to_context(c).expect("should map"); + assert_eq!(ctx.user_name.as_deref(), Some("alice@acme.dev")); + } + + #[test] + fn claims_to_context_parses_multiple_roles() { + let mut c = base_claims(); + c.org_roles = vec![ + "IT_ADMIN".to_string(), + "CXO".to_string(), + "GARBAGE".to_string(), + ]; + let ctx = claims_to_context(c).expect("should map"); + assert_eq!( + ctx.org_roles, + vec![OrgRole::ItAdmin, OrgRole::Cxo, OrgRole::Unknown] + ); + } + + #[test] + fn is_write_detects_methods() { + assert!(!is_write(&Method::GET)); + assert!(!is_write(&Method::HEAD)); + assert!(!is_write(&Method::OPTIONS)); + assert!(is_write(&Method::POST)); + assert!(is_write(&Method::PUT)); + assert!(is_write(&Method::PATCH)); + assert!(is_write(&Method::DELETE)); + } +} diff --git a/compliance-agent/src/api/mod.rs b/compliance-agent/src/api/mod.rs index f969c3b..c9009eb 100644 --- a/compliance-agent/src/api/mod.rs +++ b/compliance-agent/src/api/mod.rs @@ -2,5 +2,7 @@ pub mod auth_middleware; pub mod handlers; pub mod routes; pub mod server; +pub mod tenant_ctx; pub use server::start_api_server; +pub use tenant_ctx::TenantCtx; diff --git a/compliance-agent/src/api/tenant_ctx.rs b/compliance-agent/src/api/tenant_ctx.rs new file mode 100644 index 0000000..6ddd36d --- /dev/null +++ b/compliance-agent/src/api/tenant_ctx.rs @@ -0,0 +1,94 @@ +//! Axum extractor for the per-request `TenantContext`. +//! +//! Handlers consume it as a normal extractor argument: +//! +//! ```ignore +//! async fn list_findings(TenantCtx(ctx): TenantCtx) -> Json<...> { +//! let filter = compliance_core::db::tenant_filter(&ctx); +//! ... +//! } +//! ``` +//! +//! The middleware (`require_jwt_auth`) is responsible for inserting the +//! context into the request extensions. If it's missing on a route that +//! uses this extractor, that's a bug in the wiring — we return 401 so the +//! caller sees an auth failure rather than a 500. + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, + response::{IntoResponse, Response}, +}; +use compliance_core::TenantContext; + +#[derive(Debug, Clone)] +pub struct TenantCtx(pub TenantContext); + +#[derive(Debug)] +pub struct TenantCtxRejection; + +impl IntoResponse for TenantCtxRejection { + fn into_response(self) -> Response { + ( + StatusCode::UNAUTHORIZED, + "Missing tenant context — request was not authenticated", + ) + .into_response() + } +} + +impl FromRequestParts for TenantCtx +where + S: Send + Sync, +{ + type Rejection = TenantCtxRejection; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parts + .extensions + .get::() + .cloned() + .map(TenantCtx) + .ok_or(TenantCtxRejection) + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used)] +mod tests { + use super::*; + use axum::http::Request; + use compliance_core::TenantStatus; + + fn ctx() -> TenantContext { + TenantContext { + tenant_id: "t-1".to_string(), + tenant_slug: "acme".to_string(), + org_roles: vec![], + products: vec![], + plan: "starter".to_string(), + status: TenantStatus::Active, + user_id: "u-1".to_string(), + user_name: None, + } + } + + #[tokio::test] + async fn extracts_context_when_present() { + let mut req = Request::new(()); + req.extensions_mut().insert(ctx()); + let (mut parts, _) = req.into_parts(); + let TenantCtx(found) = TenantCtx::from_request_parts(&mut parts, &()) + .await + .expect("extractor should succeed"); + assert_eq!(found.tenant_id, "t-1"); + } + + #[tokio::test] + async fn rejects_when_missing() { + let req: Request<()> = Request::new(()); + let (mut parts, _) = req.into_parts(); + let err = TenantCtx::from_request_parts(&mut parts, &()).await; + assert!(err.is_err()); + } +} diff --git a/compliance-agent/tests/tenant_status_middleware.rs b/compliance-agent/tests/tenant_status_middleware.rs new file mode 100644 index 0000000..7049421 --- /dev/null +++ b/compliance-agent/tests/tenant_status_middleware.rs @@ -0,0 +1,123 @@ +//! M7.1 — integration tests for `require_tenant_status`. +//! +//! Exercises the middleware end-to-end through an Axum router so we +//! catch wiring bugs (extension propagation, method matching) that pure +//! unit tests would miss. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use axum::{ + body::Body, + extract::Request, + http::{Method, StatusCode}, + middleware::{from_fn, Next}, + response::Response, + routing::{get, post}, + Router, +}; +use compliance_agent::api::auth_middleware::require_tenant_status; +use compliance_core::{TenantContext, TenantStatus}; +use tower::ServiceExt; + +fn ctx_with(status: TenantStatus) -> TenantContext { + TenantContext { + tenant_id: "t-1".to_string(), + tenant_slug: "acme".to_string(), + org_roles: vec![], + products: vec![], + plan: "starter".to_string(), + status, + user_id: "u-1".to_string(), + user_name: None, + } +} + +fn router_with_ctx(ctx: Option) -> Router { + let injector = move |mut req: Request, next: Next| { + let ctx = ctx.clone(); + async move { + if let Some(c) = ctx { + req.extensions_mut().insert(c); + } + next.run(req).await + } + }; + + Router::new() + .route("/r", get(|| async { "read" })) + .route("/w", post(|| async { "write" })) + .layer(from_fn(require_tenant_status)) + .layer(from_fn(injector)) +} + +async fn call(router: Router, method: Method, path: &str) -> Response { + let req = Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("request build"); + router.oneshot(req).await.expect("oneshot") +} + +#[tokio::test] +async fn active_tenant_can_read_and_write() { + let r = router_with_ctx(Some(ctx_with(TenantStatus::Active))); + assert_eq!( + call(r.clone(), Method::GET, "/r").await.status(), + StatusCode::OK + ); + assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK); +} + +#[tokio::test] +async fn trial_tenant_can_read_and_write() { + let r = router_with_ctx(Some(ctx_with(TenantStatus::Trial))); + assert_eq!( + call(r.clone(), Method::GET, "/r").await.status(), + StatusCode::OK + ); + assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK); +} + +#[tokio::test] +async fn demo_tenant_can_read_and_write() { + let r = router_with_ctx(Some(ctx_with(TenantStatus::Demo))); + assert_eq!( + call(r.clone(), Method::GET, "/r").await.status(), + StatusCode::OK + ); + assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK); +} + +#[tokio::test] +async fn frozen_tenant_can_read_but_not_write() { + let r = router_with_ctx(Some(ctx_with(TenantStatus::Frozen))); + assert_eq!( + call(r.clone(), Method::GET, "/r").await.status(), + StatusCode::OK + ); + assert_eq!( + call(r, Method::POST, "/w").await.status(), + StatusCode::PAYMENT_REQUIRED + ); +} + +#[tokio::test] +async fn archived_tenant_is_gone_on_every_method() { + let r = router_with_ctx(Some(ctx_with(TenantStatus::Archived))); + assert_eq!( + call(r.clone(), Method::GET, "/r").await.status(), + StatusCode::GONE + ); + assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::GONE); +} + +#[tokio::test] +async fn no_context_passes_through() { + let r = router_with_ctx(None); + assert_eq!( + call(r.clone(), Method::GET, "/r").await.status(), + StatusCode::OK + ); + assert_eq!(call(r, Method::POST, "/w").await.status(), StatusCode::OK); +} diff --git a/compliance-core/src/db.rs b/compliance-core/src/db.rs new file mode 100644 index 0000000..f9d550f --- /dev/null +++ b/compliance-core/src/db.rs @@ -0,0 +1,75 @@ +//! Database helpers shared across the workspace. +//! +//! `tenant_filter` returns the BSON filter that every query and update +//! against a tenant-scoped collection MUST include. Centralising it here +//! makes the rule grep-able and keeps query call-sites from accidentally +//! omitting it. +//! +//! Future work (M7.2+): each collection model grows a `tenant_id` field +//! and every `find` / `update_*` / `delete_*` call gets this filter +//! merged in. The migration to per-collection scoping is tracked +//! separately — this helper is the building block. + +use bson::{doc, Document}; + +use crate::TenantContext; + +/// Returns `{ "tenant_id": }`. Merge this into every +/// query filter against a tenant-scoped collection. +/// +/// Use [`tenant_filter_merge`] when you need to combine it with other +/// query conditions — it preserves both halves without overwriting. +pub fn tenant_filter(ctx: &TenantContext) -> Document { + doc! { "tenant_id": &ctx.tenant_id } +} + +/// Returns the tenant filter merged with caller-supplied conditions. +/// The tenant_id always wins on key conflict — callers cannot +/// accidentally override the scoping. +pub fn tenant_filter_merge(ctx: &TenantContext, mut extra: Document) -> Document { + extra.insert("tenant_id", &ctx.tenant_id); + extra +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TenantStatus; + + fn ctx() -> TenantContext { + TenantContext { + tenant_id: "t-abc".to_string(), + tenant_slug: "acme".to_string(), + org_roles: vec![], + products: vec![], + plan: "starter".to_string(), + status: TenantStatus::Active, + user_id: "u-1".to_string(), + user_name: None, + } + } + + #[test] + fn produces_tenant_id_filter() { + let f = tenant_filter(&ctx()); + assert_eq!(f.get_str("tenant_id"), Ok("t-abc")); + assert_eq!(f.len(), 1); + } + + #[test] + fn merge_preserves_extra_conditions() { + let extra = doc! { "status": "open", "severity": "high" }; + let f = tenant_filter_merge(&ctx(), extra); + assert_eq!(f.get_str("tenant_id"), Ok("t-abc")); + assert_eq!(f.get_str("status"), Ok("open")); + assert_eq!(f.get_str("severity"), Ok("high")); + } + + #[test] + fn merge_overrides_caller_tenant_id() { + let extra = doc! { "tenant_id": "evil-other", "status": "open" }; + let f = tenant_filter_merge(&ctx(), extra); + assert_eq!(f.get_str("tenant_id"), Ok("t-abc")); + assert_eq!(f.get_str("status"), Ok("open")); + } +} diff --git a/compliance-core/src/lib.rs b/compliance-core/src/lib.rs index 4cfee7e..cd91d9b 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -1,9 +1,12 @@ pub mod config; +pub mod db; pub mod error; pub mod models; #[cfg(feature = "telemetry")] pub mod telemetry; +pub mod tenant; pub mod traits; pub use config::{AgentConfig, DashboardConfig}; pub use error::CoreError; +pub use tenant::{OrgRole, TenantContext, TenantStatus}; diff --git a/compliance-core/src/tenant.rs b/compliance-core/src/tenant.rs new file mode 100644 index 0000000..c6bcecf --- /dev/null +++ b/compliance-core/src/tenant.rs @@ -0,0 +1,165 @@ +//! Tenant context propagated through every authenticated request. +//! +//! This module is the M7.1 single source of truth for "who is this request +//! for". Claims come from a Keycloak-issued JWT and land here via +//! `compliance-agent`'s `require_jwt_auth` middleware. Handlers reach into +//! the request extensions with the `TenantCtx` Axum extractor. +//! +//! The shape mirrors the JWT claim names the breakpilot-platform realm +//! emits (see `platform/orca-platform/dev/keycloak/realm-export.json`). +//! Stable contract — adding fields is fine; renaming is a breaking +//! change for every downstream product. + +use serde::{Deserialize, Serialize}; + +/// Tenant lifecycle status from `PLATFORM_ARCHITECTURE.md §5c`. +/// +/// Drives the `tenant_status` middleware: +/// * `Demo` / `Trial` / `Active` — full access. +/// * `Frozen` — read-only after cancel / non-payment. Mutating endpoints +/// return 402. +/// * `Archived` — data-retention window closed. Every endpoint returns 410. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TenantStatus { + Demo, + Trial, + Active, + Frozen, + Archived, +} + +impl TenantStatus { + /// True for statuses that block write paths. + pub fn is_frozen(&self) -> bool { + matches!(self, TenantStatus::Frozen) + } + /// True for statuses that block every request. + pub fn is_archived(&self) -> bool { + matches!(self, TenantStatus::Archived) + } + /// True for the shared demo tenant — metering, billing, and audit + /// export are skipped. + pub fn is_demo(&self) -> bool { + matches!(self, TenantStatus::Demo) + } +} + +impl std::fmt::Display for TenantStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Demo => write!(f, "demo"), + Self::Trial => write!(f, "trial"), + Self::Active => write!(f, "active"), + Self::Frozen => write!(f, "frozen"), + Self::Archived => write!(f, "archived"), + } + } +} + +/// Org-level role baked into the JWT by the realm's protocol mapper. +/// `PLATFORM_ARCHITECTURE.md §6` is the canonical list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum OrgRole { + ItAdmin, + Cxo, + Finance, + Legal, + User, + /// Anything we haven't enumerated yet — forwards-compatible. + #[serde(other)] + Unknown, +} + +impl OrgRole { + /// Parses a single role string (Keycloak emits these as `IT_ADMIN`, + /// `CXO`, etc.). Round-trips with the JSON layer. + pub fn parse(s: &str) -> Self { + match s { + "IT_ADMIN" => OrgRole::ItAdmin, + "CXO" => OrgRole::Cxo, + "FINANCE" => OrgRole::Finance, + "LEGAL" => OrgRole::Legal, + "USER" => OrgRole::User, + _ => OrgRole::Unknown, + } + } +} + +/// Everything `compliance-agent` knows about the requesting tenant at the +/// moment a request lands. Cheap to clone (every field is owned + small). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TenantContext { + /// `tenants.id` from the platform's tenant-registry (UUID). + pub tenant_id: String, + /// Lowercase URL-safe slug. Useful for log lines + audit emit. + pub tenant_slug: String, + /// Org-level roles the authenticated user holds inside this tenant. + /// Drives the per-handler RBAC in `M7.1-followup` PRs. + pub org_roles: Vec, + /// Products this tenant is currently entitled to. Used to short-circuit + /// MCP / API calls for unsubscribed products. + pub products: Vec, + /// Customer plan (`starter` / `professional` / `enterprise`) — gates + /// per-plan feature flags (e.g., MCP server is enterprise-only). + pub plan: String, + /// Lifecycle status — read by `require_tenant_status` middleware. + pub status: TenantStatus, + /// Keycloak user id of the requester (`sub` claim). Required for audit + /// emit so we know WHO did the thing, not just WHICH tenant. + pub user_id: String, + /// Optional user-facing name from the `name` / `preferred_username` + /// claim. Only used in audit + log lines. + pub user_name: Option, +} + +impl TenantContext { + /// True if the caller holds at least one of the listed roles. + pub fn has_any_role(&self, roles: &[OrgRole]) -> bool { + self.org_roles.iter().any(|r| roles.contains(r)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn org_role_parses_known_values() { + assert_eq!(OrgRole::parse("IT_ADMIN"), OrgRole::ItAdmin); + assert_eq!(OrgRole::parse("CXO"), OrgRole::Cxo); + assert_eq!(OrgRole::parse("USER"), OrgRole::User); + } + + #[test] + fn org_role_unknown_is_forward_compat() { + assert_eq!(OrgRole::parse("FUTURE_ROLE"), OrgRole::Unknown); + } + + #[test] + fn tenant_status_predicates() { + assert!(TenantStatus::Frozen.is_frozen()); + assert!(!TenantStatus::Active.is_frozen()); + assert!(TenantStatus::Archived.is_archived()); + assert!(TenantStatus::Demo.is_demo()); + assert!(!TenantStatus::Active.is_demo()); + } + + #[test] + fn has_any_role_matches() { + let ctx = TenantContext { + tenant_id: "t1".into(), + tenant_slug: "acme".into(), + org_roles: vec![OrgRole::ItAdmin], + products: vec![], + plan: "professional".into(), + status: TenantStatus::Active, + user_id: "u".into(), + user_name: None, + }; + assert!(ctx.has_any_role(&[OrgRole::ItAdmin])); + assert!(ctx.has_any_role(&[OrgRole::Cxo, OrgRole::ItAdmin])); + assert!(!ctx.has_any_role(&[OrgRole::User, OrgRole::Cxo])); + } +} -- 2.52.0 From cb7b1b86f5021f91a46bdd5b51095d7f1f46d446 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 20 May 2026 17:20:37 +0200 Subject: [PATCH 2/2] fix(m7.1): correct middleware layer order so JwksState is visible Axum applies layers outermost-first. With the previous ordering (`Extension(jwks_state)` first, `require_jwt_auth` last), the JWT middleware ran before the Extension layer attached `JwksState` to the request, so `request.extensions().get::()` always returned None and the middleware silently passed through every request as if Keycloak weren't configured. Verified end-to-end against the local CERTifAI Keycloak realm: - no token / bad token -> 401 - active / trial -> 200 read, write reaches handler - frozen -> 200 read, 402 on writes - archived -> 410 on every method The bug was invisible to the unit + integration tests because they construct the layer stack manually; only the live wiring exhibited it. Co-Authored-By: Claude Opus 4.7 (1M context) --- compliance-agent/src/api/server.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/compliance-agent/src/api/server.rs b/compliance-agent/src/api/server.rs index 9b89714..99d9248 100644 --- a/compliance-agent/src/api/server.rs +++ b/compliance-agent/src/api/server.rs @@ -8,7 +8,7 @@ use tower_http::set_header::SetResponseHeaderLayer; use tower_http::trace::TraceLayer; use crate::agent::ComplianceAgent; -use crate::api::auth_middleware::{require_jwt_auth, JwksState}; +use crate::api::auth_middleware::{require_jwt_auth, require_tenant_status, JwksState}; use crate::api::routes; use crate::error::AgentError; @@ -44,9 +44,14 @@ pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), A jwks_url, }; tracing::info!("Keycloak JWT auth enabled for realm '{kc_realm}'"); + // Layers execute outermost-first. The Extension must run before + // require_jwt_auth so that middleware can read JwksState from + // request extensions, and the status gate must run after the + // JWT auth so TenantContext is in extensions. app = app - .layer(Extension(jwks_state)) - .layer(middleware::from_fn(require_jwt_auth)); + .layer(middleware::from_fn(require_tenant_status)) + .layer(middleware::from_fn(require_jwt_auth)) + .layer(Extension(jwks_state)); } else { tracing::warn!("Keycloak not configured - API endpoints are unprotected"); } -- 2.52.0