feat(core): lift M7.1 tenant types, db helper, and auth middleware into compliance-core

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>
This commit is contained in:
Sharang Parnerkar
2026-06-04 11:07:31 +02:00
parent a8cef58e02
commit f583d0788c
6 changed files with 662 additions and 0 deletions
+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]));
}
}