Compare commits

...

2 Commits

Author SHA1 Message Date
sharang dbadff0aac fix(m7.1): JWKS refresh-on-failure in auth middleware (#84)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 11m44s
CI / Deploy Dashboard (push) Successful in 13m1s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m53s
2026-06-04 14:46:14 +00:00
sharang 116293519d M7.1 smoke harness: lift auth to compliance-core + compliance-smoke service (#83)
CI / Check (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
2026-06-04 14:38:35 +00:00
11 changed files with 1034 additions and 0 deletions
Generated
+18
View File
@@ -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"
+1
View File
@@ -6,6 +6,7 @@ members = [
"compliance-graph",
"compliance-dast",
"compliance-mcp",
"compliance-smoke",
]
resolver = "2"
+13
View File
@@ -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 }
+390
View File
@@ -0,0 +1,390 @@
//! 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<RwLock<Option<JwkSet>>>,
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<String>,
#[serde(default)]
preferred_username: Option<String>,
#[serde(default)]
tenant_id: String,
#[serde(default)]
tenant_slug: String,
#[serde(default)]
org_roles: Vec<String>,
#[serde(default)]
products: Vec<String>,
#[serde(default)]
plan: String,
#[serde(default)]
tenant_status: Option<TenantStatus>,
}
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::<JwksState>() {
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::<TenantContext>() {
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<TenantContext, String> {
let header = decode_header(token).map_err(|e| format!("failed to decode JWT header: {e}"))?;
let kid = header
.kid
.clone()
.ok_or_else(|| "JWT missing kid header".to_string())?;
// First try against whatever's currently cached. If the kid isn't
// there or the signature doesn't verify, the cached JWKS is most
// likely stale (KC rotated keys) — refresh once and retry before
// giving up. Without this every key rotation produces a silent 401
// storm that only goes away when the agent restarts.
let jwks = fetch_or_get_jwks(state, false).await?;
match try_validate(token, &header, &kid, &jwks) {
Ok(ctx) => Ok(ctx),
Err(ValidationError::Permanent(e)) => Err(e),
Err(ValidationError::Stale(reason)) => {
tracing::info!(
kid = %kid,
reason = %reason,
"JWKS appears stale — forcing refresh and retrying"
);
let jwks = fetch_or_get_jwks(state, true).await?;
try_validate(token, &header, &kid, &jwks).map_err(|e| match e {
ValidationError::Stale(s) | ValidationError::Permanent(s) => s,
})
}
}
}
#[derive(Debug)]
enum ValidationError {
/// Refresh-eligible: cached JWKS may be stale.
Stale(String),
/// Refusing the token regardless of JWKS freshness.
Permanent(String),
}
fn try_validate(
token: &str,
header: &jsonwebtoken::Header,
kid: &str,
jwks: &JwkSet,
) -> Result<TenantContext, ValidationError> {
let jwk = match jwks
.keys
.iter()
.find(|k| k.common.key_id.as_deref() == Some(kid))
{
Some(j) => j,
None => {
return Err(ValidationError::Stale(
"no matching key found in JWKS".to_string(),
))
}
};
let decoding_key = DecodingKey::from_jwk(jwk)
.map_err(|e| ValidationError::Permanent(format!("failed to create decoding key: {e}")))?;
let mut validation = Validation::new(header.alg);
validation.validate_exp = true;
validation.validate_aud = false;
let data = match decode::<Claims>(token, &decoding_key, &validation) {
Ok(d) => d,
Err(e) => {
// Signature mismatch is the other refresh-eligible failure:
// the matching kid is present but the key bytes don't match.
// Everything else (expired, malformed, etc.) is permanent.
return Err(
if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::InvalidSignature) {
ValidationError::Stale(format!("token validation failed: {e}"))
} else {
ValidationError::Permanent(format!("token validation failed: {e}"))
},
);
}
};
claims_to_context(data.claims).map_err(ValidationError::Permanent)
}
/// 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<TenantContext, String> {
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, force: bool) -> Result<JwkSet, String> {
if !force {
let cached = state.jwks.read().await;
if let Some(ref jwks) = *cached {
return Ok(jwks.clone());
}
}
// Hold the write lock across the fetch so concurrent refreshers
// don't all hammer Keycloak when keys rotate. If another writer
// already populated a fresh JWKS while we were waiting (and we
// weren't asked to force), use theirs.
let mut cached = state.jwks.write().await;
if !force {
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}"))?;
*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 try_validate_returns_stale_when_kid_missing_from_jwks() {
// Empty JWKS — the kid we ask for can't possibly match. The error
// must classify as Stale so the caller refreshes JWKS and retries.
let jwks = JwkSet { keys: vec![] };
let header = jsonwebtoken::Header {
alg: jsonwebtoken::Algorithm::RS256,
kid: Some("kid-rotated-out".to_string()),
..Default::default()
};
let err = try_validate("ignored.token.value", &header, "kid-rotated-out", &jwks)
.expect_err("should fail");
match err {
ValidationError::Stale(s) => assert!(s.contains("no matching key")),
ValidationError::Permanent(s) => panic!("must be Stale, got Permanent: {s}"),
}
}
#[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));
}
}
+75
View File
@@ -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": <ctx.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"));
}
}
+8
View File
@@ -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};
+165
View File
@@ -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<OrgRole>,
/// Products this tenant is currently entitled to. Used to short-circuit
/// MCP / API calls for unsubscribed products.
pub products: Vec<String>,
/// 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<String>,
}
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]));
}
}
+95
View File
@@ -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<S> FromRequestParts<S> for TenantCtx
where
S: Send + Sync,
{
type Rejection = TenantCtxRejection;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<TenantContext>()
.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());
}
}
+22
View File
@@ -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 }
+111
View File
@@ -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<String>,
org_roles: Vec<String>,
user_id: String,
user_name: Option<String>,
}
async fn health() -> Json<serde_json::Value> {
Json(serde_json::json!({ "ok": true }))
}
async fn echo_read(TenantCtx(ctx): TenantCtx) -> Json<EchoResponse> {
Json(echo(ctx, "GET"))
}
async fn echo_write(TenantCtx(ctx): TenantCtx) -> Json<EchoResponse> {
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<dyn std::error::Error>> {
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(())
}
+136
View File
@@ -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"