feat(m7.1): wire tenant claims, status enforcement, and db scoping helper
CI / Check (pull_request) Successful in 10m50s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
CI / Check (pull_request) Successful in 10m50s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
Lays the platform-wide multi-tenancy infrastructure on top of the existing Keycloak signature validation. JWTs now carry tenant_id, tenant_slug, org_roles, products, plan, and tenant_status; the middleware decodes them into a TenantContext and attaches it to the request extensions. A TenantCtx Axum extractor exposes the context to handlers, and a tenant_status middleware enforces the §5c lifecycle (frozen tenants are 402 on writes; archived tenants are 410 on every method). A db::tenant_filter helper in compliance-core gives every future collection a single grep-able pattern for tenant-scoped queries. Per-collection wiring (adding tenant_id to each model + threading the filter through every find/update/delete call) lands in a follow-up. Tests: 6 inline unit tests for claims→context mapping, 2 for the extractor, 6 integration tests for status middleware, 3 for db filter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
#[cfg(feature = "telemetry")]
|
||||
pub mod telemetry;
|
||||
pub mod tenant;
|
||||
pub mod traits;
|
||||
|
||||
pub use config::{AgentConfig, DashboardConfig};
|
||||
pub use error::CoreError;
|
||||
pub use tenant::{OrgRole, TenantContext, TenantStatus};
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Tenant context propagated through every authenticated request.
|
||||
//!
|
||||
//! This module is the M7.1 single source of truth for "who is this request
|
||||
//! for". Claims come from a Keycloak-issued JWT and land here via
|
||||
//! `compliance-agent`'s `require_jwt_auth` middleware. Handlers reach into
|
||||
//! the request extensions with the `TenantCtx` Axum 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 `compliance-agent` knows 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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user