//! 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])); } }