From f583d0788c02a500f83667d4a5dc81dc70114db3 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:07:31 +0200 Subject: [PATCH] feat(core): lift M7.1 tenant types, db helper, and auth middleware into compliance-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the tenant-aware HTTP infrastructure into compliance-core so every future product (compliance-agent, compliance-dast, compliance-mcp, the upcoming smoke harness) shares one source of truth instead of each crate re-implementing claims extraction and the status gate. * tenant.rs — TenantStatus / OrgRole / TenantContext (unconditional) * db.rs — tenant_filter + tenant_filter_merge for query scoping * auth.rs — require_jwt_auth + require_tenant_status + JwksState * tenant_ctx.rs — Axum TenantCtx extractor * `axum` cargo feature gates the HTTP-dependent modules so wasm consumers (the dashboard frontend) don't pull axum/jsonwebtoken/reqwest 40 unit tests across the moved modules — all green. Co-Authored-By: Claude Opus 4.7 --- compliance-core/Cargo.toml | 13 ++ compliance-core/src/auth.rs | 306 ++++++++++++++++++++++++++++++ compliance-core/src/db.rs | 75 ++++++++ compliance-core/src/lib.rs | 8 + compliance-core/src/tenant.rs | 165 ++++++++++++++++ compliance-core/src/tenant_ctx.rs | 95 ++++++++++ 6 files changed, 662 insertions(+) create mode 100644 compliance-core/src/auth.rs create mode 100644 compliance-core/src/db.rs create mode 100644 compliance-core/src/tenant.rs create mode 100644 compliance-core/src/tenant_ctx.rs 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()); + } +}