diff --git a/Cargo.lock b/Cargo.lock index 0714ef6..efd22e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,19 +700,23 @@ dependencies = [ name = "compliance-core" version = "0.1.0" dependencies = [ + "axum", "bson", "chrono", "hex", + "jsonwebtoken", "mongodb", "opentelemetry", "opentelemetry-appender-tracing", "opentelemetry-otlp", "opentelemetry_sdk", + "reqwest", "secrecy", "serde", "serde_json", "sha2", "thiserror 2.0.18", + "tokio", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -826,6 +830,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "compliance-smoke" +version = "0.1.0" +dependencies = [ + "axum", + "compliance-core", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index e1b0315..b09af4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "compliance-graph", "compliance-dast", "compliance-mcp", + "compliance-smoke", ] resolver = "2" diff --git a/compliance-core/Cargo.toml b/compliance-core/Cargo.toml index 2b98e4e..e1b5c50 100644 --- a/compliance-core/Cargo.toml +++ b/compliance-core/Cargo.toml @@ -18,6 +18,15 @@ telemetry = [ "dep:tracing-subscriber", "dep:tracing", ] +# Pulls in the M7.1 Axum middleware + extractor. Consumers that don't +# embed an HTTP server (e.g. the wasm dashboard frontend) leave it off. +axum = [ + "dep:axum", + "dep:jsonwebtoken", + "dep:reqwest", + "dep:tokio", + "dep:tracing", +] [dependencies] serde = { workspace = true } @@ -37,3 +46,7 @@ opentelemetry-appender-tracing = { version = "0.29", optional = true } tracing-opentelemetry = { version = "0.30", optional = true } tracing-subscriber = { workspace = true, optional = true } tracing = { workspace = true, optional = true } +axum = { version = "0.8", optional = true } +jsonwebtoken = { version = "9", optional = true } +reqwest = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } diff --git a/compliance-core/src/auth.rs b/compliance-core/src/auth.rs new file mode 100644 index 0000000..3d54c43 --- /dev/null +++ b/compliance-core/src/auth.rs @@ -0,0 +1,306 @@ +//! 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 +//! ([`crate::tenant_ctx::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 jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation}; +use reqwest::StatusCode; +use serde::Deserialize; +use tokio::sync::RwLock; + +use crate::{OrgRole, TenantContext, TenantStatus}; + +/// Cached JWKS from Keycloak for token validation. +#[derive(Clone)] +pub struct JwksState { + pub jwks: Arc>>, + 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 { + 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 +/// 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. +pub async fn require_jwt_auth(mut request: Request, next: Next) -> Response { + let path = request.uri().path(); + + if PUBLIC_ENDPOINTS.contains(&path) { + return next.run(request).await; + } + + let jwks_state = match request.extensions().get::() { + Some(s) => s.clone(), + None => return next.run(request).await, + }; + + let auth_header = match request.headers().get("authorization") { + Some(h) => h, + None => return (StatusCode::UNAUTHORIZED, "Missing authorization header").into_response(), + }; + + let token = match auth_header.to_str() { + Ok(s) if s.starts_with("Bearer ") => &s[7..], + _ => return (StatusCode::UNAUTHORIZED, "Invalid authorization header").into_response(), + }; + + match validate_token(token, &jwks_state).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() + } + } +} + +/// 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 + .kid + .ok_or_else(|| "JWT missing kid header".to_string())?; + + let jwks = fetch_or_get_jwks(state).await?; + + let jwk = jwks + .keys + .iter() + .find(|k| k.common.key_id.as_deref() == Some(&kid)) + .ok_or_else(|| "no matching key found in JWKS".to_string())?; + + let decoding_key = + DecodingKey::from_jwk(jwk).map_err(|e| format!("failed to create decoding key: {e}"))?; + + let mut validation = Validation::new(header.alg); + validation.validate_exp = true; + validation.validate_aud = false; + + let data = decode::(token, &decoding_key, &validation) + .map_err(|e| format!("token validation failed: {e}"))?; + + 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 { + { + let cached = state.jwks.read().await; + if let Some(ref jwks) = *cached { + return Ok(jwks.clone()); + } + } + + let resp = reqwest::get(&state.jwks_url) + .await + .map_err(|e| format!("failed to fetch JWKS: {e}"))?; + + let jwks: JwkSet = resp + .json() + .await + .map_err(|e| format!("failed to parse JWKS: {e}"))?; + + let mut cached = state.jwks.write().await; + *cached = Some(jwks.clone()); + + 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-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..6fd7c50 100644 --- a/compliance-core/src/lib.rs +++ b/compliance-core/src/lib.rs @@ -1,9 +1,17 @@ pub mod config; +pub mod db; pub mod error; pub mod models; #[cfg(feature = "telemetry")] pub mod telemetry; +pub mod tenant; pub mod traits; +#[cfg(feature = "axum")] +pub mod auth; +#[cfg(feature = "axum")] +pub mod tenant_ctx; + 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..bf0ddf2 --- /dev/null +++ b/compliance-core/src/tenant.rs @@ -0,0 +1,165 @@ +//! Tenant context propagated through every authenticated request. +//! +//! M7.1 single source of truth for "who is this request for". Claims come +//! from a Keycloak-issued JWT and land here via [`crate::auth::require_jwt_auth`] +//! (enabled with the `axum` feature). Handlers reach into the request +//! extensions with the [`crate::tenant_ctx::TenantCtx`] 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 we know 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])); + } +} diff --git a/compliance-core/src/tenant_ctx.rs b/compliance-core/src/tenant_ctx.rs new file mode 100644 index 0000000..d528fa3 --- /dev/null +++ b/compliance-core/src/tenant_ctx.rs @@ -0,0 +1,95 @@ +//! 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 ([`crate::auth::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 crate::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 crate::TenantStatus; + use axum::http::Request; + + 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-smoke/Cargo.toml b/compliance-smoke/Cargo.toml new file mode 100644 index 0000000..6dfc58b --- /dev/null +++ b/compliance-smoke/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "compliance-smoke" +version = "0.1.0" +edition = "2021" +description = "Tiny Axum service exercising compliance-core M7.1 tenant gating. Run smoke.sh against it before merging anything that touches the auth/tenant path." + +[lints] +workspace = true + +[[bin]] +name = "compliance-smoke" +path = "src/main.rs" + +[dependencies] +compliance-core = { workspace = true, features = ["axum"] } +axum = "0.8" +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true } diff --git a/compliance-smoke/src/main.rs b/compliance-smoke/src/main.rs new file mode 100644 index 0000000..fc14c3b --- /dev/null +++ b/compliance-smoke/src/main.rs @@ -0,0 +1,111 @@ +//! M7.1 smoke service. +//! +//! A standalone Axum binary whose only job is to host the +//! [`compliance_core::auth`] middleware + [`compliance_core::tenant_ctx`] +//! extractor on three endpoints, so `scripts/smoke.sh` can prove the +//! tenant-gating contract end-to-end before any auth-path PR merges. +//! +//! Endpoints: +//! * `GET /api/v1/health` — public, never authenticated. +//! * `GET /api/v1/echo` — protected read; returns the [`TenantContext`]. +//! * `POST /api/v1/echo` — protected write; exercises the `Frozen → 402` +//! gate on the same handler. +//! +//! Configuration (env): +//! * `KEYCLOAK_URL` — e.g. `http://localhost:8080`. Required. +//! * `KEYCLOAK_REALM` — e.g. `certifai`. Required. +//! * `SMOKE_PORT` — defaults to `3010`. + +use std::sync::Arc; + +use axum::{middleware, routing::get, Extension, Json, Router}; +use compliance_core::{ + auth::{require_jwt_auth, require_tenant_status, JwksState}, + tenant_ctx::TenantCtx, +}; +use serde::Serialize; +use tokio::sync::RwLock; + +#[derive(Serialize)] +struct EchoResponse { + method: &'static str, + tenant_id: String, + tenant_slug: String, + plan: String, + status: String, + products: Vec, + org_roles: Vec, + user_id: String, + user_name: Option, +} + +async fn health() -> Json { + Json(serde_json::json!({ "ok": true })) +} + +async fn echo_read(TenantCtx(ctx): TenantCtx) -> Json { + Json(echo(ctx, "GET")) +} + +async fn echo_write(TenantCtx(ctx): TenantCtx) -> Json { + Json(echo(ctx, "POST")) +} + +fn echo(ctx: compliance_core::TenantContext, method: &'static str) -> EchoResponse { + EchoResponse { + method, + tenant_id: ctx.tenant_id, + tenant_slug: ctx.tenant_slug, + plan: ctx.plan, + status: ctx.status.to_string(), + products: ctx.products, + org_roles: ctx.org_roles.iter().map(|r| format!("{r:?}")).collect(), + user_id: ctx.user_id, + user_name: ctx.user_name, + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let kc_url = std::env::var("KEYCLOAK_URL") + .map_err(|_| "KEYCLOAK_URL is required (e.g. http://localhost:8080)")?; + let kc_realm = std::env::var("KEYCLOAK_REALM") + .map_err(|_| "KEYCLOAK_REALM is required (e.g. certifai)")?; + let port: u16 = std::env::var("SMOKE_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(3010); + + let jwks_url = format!("{kc_url}/realms/{kc_realm}/protocol/openid-connect/certs"); + let jwks_state = JwksState { + jwks: Arc::new(RwLock::new(None)), + jwks_url: jwks_url.clone(), + }; + + // Layers execute outermost-first. The Extension must be registered + // before `require_jwt_auth` so the middleware can read JwksState; the + // status gate must run after JWT so `TenantContext` is in extensions. + let app = Router::new() + .route("/api/v1/health", get(health)) + .route("/api/v1/echo", get(echo_read).post(echo_write)) + .layer(middleware::from_fn(require_tenant_status)) + .layer(middleware::from_fn(require_jwt_auth)) + .layer(Extension(jwks_state)); + + let addr = format!("0.0.0.0:{port}"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!( + port, + jwks = %jwks_url, + "compliance-smoke listening — try `scripts/smoke.sh`" + ); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..8417a23 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# M7.1 tenant-gating smoke test. +# +# Drives compliance-smoke against a live Keycloak realm with five test +# users (one per tenant_status), asserts the response code on each +# endpoint, and exits non-zero on any mismatch. +# +# Pre-reqs (one-time): +# * KC up at $KC_URL with realm $KC_REALM +# * Client $KC_CLIENT has direct-access-grants enabled +# * Users + tenant_status mappers per certifai/keycloak/realm-export.json +# * compliance-smoke binary running and reachable at $SMOKE_URL +# +# Usage: +# scripts/smoke.sh # uses defaults below +# SMOKE_URL=... scripts/smoke.sh + +set -euo pipefail + +KC_URL="${KC_URL:-http://localhost:8080}" +KC_REALM="${KC_REALM:-certifai}" +KC_CLIENT="${KC_CLIENT:-certifai-dashboard}" +SMOKE_URL="${SMOKE_URL:-http://localhost:3010}" + +readonly TOKEN_ENDPOINT="${KC_URL}/realms/${KC_REALM}/protocol/openid-connect/token" + +PASS=0 +FAIL=0 + +red() { printf '\033[31m%s\033[0m' "$*"; } +green() { printf '\033[32m%s\033[0m' "$*"; } +yellow() { printf '\033[33m%s\033[0m' "$*"; } + +# Fetches an access token via direct access grant. Echoes the raw token. +get_token() { + local user="$1" pass="$2" + curl -sS -X POST "$TOKEN_ENDPOINT" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d "grant_type=password" \ + -d "client_id=${KC_CLIENT}" \ + -d "username=${user}" \ + -d "password=${pass}" \ + -d "scope=openid" \ + | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' +} + +# Hits SMOKE_URL$path with the given method and (optional) bearer token, +# asserts the response status code matches $want. +assert_status() { + local label="$1" method="$2" path="$3" want="$4" token="${5:-}" + local args=(-sS -o /dev/null -w '%{http_code}' -X "$method" "${SMOKE_URL}${path}") + if [[ -n "$token" ]]; then + args+=(-H "Authorization: Bearer ${token}") + fi + local got + got=$(curl "${args[@]}") + if [[ "$got" == "$want" ]]; then + printf ' %s %s %-4s %-15s → %s\n' "$(green PASS)" "$label" "$method" "$path" "$got" + PASS=$((PASS + 1)) + else + printf ' %s %s %-4s %-15s → got %s, want %s\n' "$(red FAIL)" "$label" "$method" "$path" "$got" "$want" + FAIL=$((FAIL + 1)) + fi +} + +header() { + printf '\n%s %s\n' "$(yellow '##')" "$1" +} + +# ---- Pre-flight ---------------------------------------------------------- +header "Pre-flight" +if ! curl -sS -o /dev/null -w '%{http_code}\n' "${SMOKE_URL}/api/v1/health" | grep -q '^200$'; then + printf ' %s smoke service not reachable at %s\n' "$(red ERR)" "$SMOKE_URL" + exit 2 +fi +if ! curl -sS -o /dev/null -w '%{http_code}\n' "${KC_URL}/realms/${KC_REALM}/.well-known/openid-configuration" | grep -q '^200$'; then + printf ' %s Keycloak realm %s not reachable at %s\n' "$(red ERR)" "$KC_REALM" "$KC_URL" + exit 2 +fi +printf ' %s smoke service + Keycloak both up\n' "$(green OK)" + +# ---- Public endpoint -------------------------------------------------- +header "Public endpoint (no auth required)" +assert_status anon GET /api/v1/health 200 + +# ---- Anonymous access to protected endpoints ---------------------------- +header "Anonymous → 401 on protected endpoints" +assert_status anon GET /api/v1/echo 401 +assert_status anon POST /api/v1/echo 401 + +# ---- Bad token ---------------------------------------------------------- +header "Bad token → 401" +assert_status bogus GET /api/v1/echo 401 "not-a-real-jwt" +assert_status bogus POST /api/v1/echo 401 "not-a-real-jwt" + +# ---- Active tenant (admin user) ----------------------------------------- +header "admin@certifai.local (active) → full access" +TOKEN=$(get_token admin@certifai.local admin) +if [[ -z "$TOKEN" ]]; then + printf ' %s failed to fetch token for admin\n' "$(red ERR)" + exit 2 +fi +assert_status active GET /api/v1/echo 200 "$TOKEN" +assert_status active POST /api/v1/echo 200 "$TOKEN" + +# ---- Active tenant (USER role) ------------------------------------------ +header "user@certifai.local (active) → full access" +TOKEN=$(get_token user@certifai.local user) +assert_status active GET /api/v1/echo 200 "$TOKEN" +assert_status active POST /api/v1/echo 200 "$TOKEN" + +# ---- Trial tenant ------------------------------------------------------- +header "trial@acme.local (trial) → full access" +TOKEN=$(get_token trial@acme.local trial) +assert_status trial GET /api/v1/echo 200 "$TOKEN" +assert_status trial POST /api/v1/echo 200 "$TOKEN" + +# ---- Frozen tenant ------------------------------------------------------ +header "frozen@acme.local (frozen) → read-only, writes 402" +TOKEN=$(get_token frozen@acme.local frozen) +assert_status frozen GET /api/v1/echo 200 "$TOKEN" +assert_status frozen POST /api/v1/echo 402 "$TOKEN" + +# ---- Archived tenant ---------------------------------------------------- +header "archived@acme.local (archived) → 410 everywhere" +TOKEN=$(get_token archived@acme.local archived) +assert_status archived GET /api/v1/echo 410 "$TOKEN" +assert_status archived POST /api/v1/echo 410 "$TOKEN" + +# ---- Summary ------------------------------------------------------------ +printf '\n' +if [[ "$FAIL" -gt 0 ]]; then + printf '%s %d passed, %d failed\n' "$(red FAIL)" "$PASS" "$FAIL" + exit 1 +fi +printf '%s %d/%d assertions passed\n' "$(green PASS)" "$PASS" "$PASS"