f583d0788c
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 <noreply@anthropic.com>
166 lines
5.7 KiB
Rust
166 lines
5.7 KiB
Rust
//! 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]));
|
|
}
|
|
}
|