diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 09cf3ae..6575172 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -300,16 +300,28 @@ pub fn inject_tenant_scope( let tenant_chain: Vec = claims .custom_claim_as::>("tenant_chain") .unwrap_or_default(); - if let Some(tenant_ref) = tenant_chain.last() { - let tenant_filter = Filter::eq( - FieldPath::single("_tenant"), - DynamicValue::Text(tenant_ref.entity_id.clone()), - ); - query.filter = Some(match query.filter.take() { - Some(existing) => Filter::and(vec![existing, tenant_filter]), - None => tenant_filter, - }); + if tenant_chain.is_empty() { + return; } + // The Cedar adapter pushes every chain entry into `principal.parents` + // so `_tenant in principal` matches any chain entry; the query-time + // filter must do the same. The `tenant_scope` middleware has already + // narrowed `tenant_chain` to the per-request effective scope (active + // leaf + ancestors), so `IN ` here filters by exactly the rows + // the policy would also allow. + let tenant_values: Vec = tenant_chain + .iter() + .map(|t| DynamicValue::Text(t.entity_id.clone())) + .collect(); + let tenant_filter = if tenant_values.len() == 1 { + Filter::eq(FieldPath::single("_tenant"), tenant_values.into_iter().next().unwrap()) + } else { + Filter::in_set(FieldPath::single("_tenant"), tenant_values) + }; + query.filter = Some(match query.filter.take() { + Some(existing) => Filter::and(vec![existing, tenant_filter]), + None => tenant_filter, + }); } /// Inject `_tenant` field into entity fields on creation. @@ -628,6 +640,51 @@ mod tests { assert!(query.filter.is_none()); } + #[test] + fn inject_tenant_scope_uses_in_set_for_multi_entry_chain() { + let tenant_config = make_enabled_tenant_config(); + let mut claims = make_claims(&["member"]); + claims.custom.insert( + "tenant_chain".to_string(), + serde_json::json!([ + {"schema": "Organization", "entity_id": "org-a"}, + {"schema": "Department", "entity_id": "dept-1"}, + ]), + ); + let mut query = Query::new(SchemaId::new()); + + inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + + let filter = query.filter.expect("filter set"); + match filter { + Filter::In { path, values } => { + assert_eq!(path.root(), "_tenant"); + assert_eq!(values.len(), 2); + let extracted: Vec<&str> = values + .iter() + .filter_map(|v| match v { + DynamicValue::Text(s) => Some(s.as_str()), + _ => None, + }) + .collect(); + assert!(extracted.contains(&"org-a")); + assert!(extracted.contains(&"dept-1")); + } + other => panic!("expected In filter, got: {other:?}"), + } + } + + #[test] + fn inject_tenant_scope_noop_when_chain_is_empty() { + let tenant_config = make_enabled_tenant_config(); + let claims = make_claims(&["member"]); + let mut query = Query::new(SchemaId::new()); + + inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + + assert!(query.filter.is_none()); + } + #[test] fn inject_tenant_scope_combines_with_existing_filter() { let tenant_config = make_enabled_tenant_config(); diff --git a/crates/schema-forge-acton/src/lib.rs b/crates/schema-forge-acton/src/lib.rs index e7a984c..c968c96 100644 --- a/crates/schema-forge-acton/src/lib.rs +++ b/crates/schema-forge-acton/src/lib.rs @@ -11,6 +11,7 @@ pub mod extension; pub mod graphql; pub mod hooks; pub mod messages; +pub mod middleware; pub mod routes; pub mod shared; pub mod shared_auth; diff --git a/crates/schema-forge-acton/src/middleware/mod.rs b/crates/schema-forge-acton/src/middleware/mod.rs new file mode 100644 index 0000000..5d6a22e --- /dev/null +++ b/crates/schema-forge-acton/src/middleware/mod.rs @@ -0,0 +1,7 @@ +//! Cross-cutting HTTP middleware for the JSON forge API. +//! +//! Each sub-module exposes a single `axum::middleware::from_fn`-style +//! middleware plus any state struct it needs. Middleware is layered onto +//! the versioned router in `schema_forge_cli::commands::serve`. + +pub mod tenant_scope; diff --git a/crates/schema-forge-acton/src/middleware/tenant_scope.rs b/crates/schema-forge-acton/src/middleware/tenant_scope.rs new file mode 100644 index 0000000..f600a4a --- /dev/null +++ b/crates/schema-forge-acton/src/middleware/tenant_scope.rs @@ -0,0 +1,704 @@ +//! `tenant_scope` middleware — per-request active-tenant validation and +//! hierarchy walk. +//! +//! The login handler ships a token whose `tenant_chain` custom claim is +//! the *flat* set of tenants the user belongs to. This middleware sits +//! between the acton-service token middleware and the SchemaForge route +//! handlers and rewrites that claim into the *effective* per-request +//! scope: +//! +//! 1. Reads `X-Active-Tenant: :` from the request +//! (or defaults to the user's single membership if exactly one). +//! 2. Validates the active tenant is in the user's memberships (rejects +//! impersonation: a token holder cannot claim a tenant they aren't a +//! member of). +//! 3. Walks the [`TenantConfig`] hierarchy from the active leaf up to +//! root, fetching each level's entity row to read its `parent_field` +//! ref. The resulting walk is what downstream code (`access.rs`, +//! `authz/adapters.rs`) reads from the claim. +//! +//! Bypass paths: +//! - `TenantConfig` is `None` or disabled → passthrough. +//! - No `Claims` in request extensions → passthrough (public routes +//! served before token middleware sees them; protected routes that +//! reach here without claims will 401 downstream regardless). +//! - `platform_admin` role → passthrough. Matches the existing +//! `access.rs::inject_tenant_scope` bypass at line 297. +//! - Empty `tenant_chain` claim (e.g. a `platform_admin` whose login +//! produced no memberships) → passthrough with the claim untouched. + +use std::sync::Arc; + +use acton_service::middleware::Claims; +use axum::extract::State; +use axum::http::{Request, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use schema_forge_backend::tenant::{TenantConfig, TenantRef}; +use schema_forge_backend::DynEntityStore; +use schema_forge_core::types::{DynamicValue, SchemaName}; + +use crate::access::PLATFORM_ADMIN_ROLE; + +/// State injected into the [`middleware`] function via +/// [`axum::middleware::from_fn_with_state`]. +#[derive(Clone)] +pub struct TenantScopeState { + /// Backend handle for fetching tenant entities during the hierarchy walk. + pub entity_store: Arc, + /// Tenant configuration derived from `@tenant` annotations on + /// registered schemas. `None` (or `Some(cfg)` with + /// `cfg.is_enabled() == false`) disables this middleware entirely. + pub tenant_config: Arc>, +} + +/// `X-Active-Tenant: :` header name. +pub const ACTIVE_TENANT_HEADER: &str = "x-active-tenant"; + +/// Error envelope returned for tenant-scope refusals. +/// +/// Matches the shape used by `routes::auth::LoginErrorBody` so clients +/// can parse error codes uniformly across auth- and authz-style refusals. +#[derive(serde::Serialize)] +struct TenantScopeError { + error: &'static str, + code: &'static str, + status: u16, +} + +/// The middleware function. Wire with +/// `axum::middleware::from_fn_with_state(state, tenant_scope::middleware)` +/// AFTER the token middleware (so Claims exist in extensions) and BEFORE +/// the forge route handlers. +pub async fn middleware( + State(state): State, + mut request: Request, + next: Next, +) -> Response +where + B: Send + 'static, + Request: Into>, +{ + // Tenancy disabled at the deployment level — nothing to do. + let Some(tenant_config) = state.tenant_config.as_ref() else { + return next.run(request.into()).await; + }; + if !tenant_config.is_enabled() { + return next.run(request.into()).await; + } + + let Some(claims) = request.extensions().get::().cloned() else { + // Public route or unauthenticated request — let the downstream + // 401 path (or public-route allow) handle it. + return next.run(request.into()).await; + }; + + if claims.has_role(PLATFORM_ADMIN_ROLE) { + // Platform admin bypasses tenancy. Pass claims through unmodified. + return next.run(request.into()).await; + } + + let memberships: Vec = claims + .custom_claim_as("tenant_chain") + .unwrap_or_default(); + + // Empty memberships means the login handler refused or the user is a + // platform_admin caught upstream; downstream `_tenant in principal` + // will simply not match anything. Pass through. + if memberships.is_empty() { + return next.run(request.into()).await; + } + + let active = match select_active_tenant(&request, &memberships) { + Ok(t) => t, + Err(refusal) => return refusal.into_response(), + }; + + let effective = match walk_to_root(active, tenant_config, state.entity_store.as_ref()).await { + Ok(walk) => walk, + Err(WalkError::EntityMissing { schema, entity_id }) => { + tracing::warn!( + schema = %schema, + entity_id = %entity_id, + "tenant_scope: leaf entity missing during hierarchy walk; \ + effective chain reduced to leaf-only" + ); + // Falling back to leaf-only keeps the request servable; Cedar + // `_tenant in principal` will still match the leaf even if + // ancestors couldn't be resolved. + vec![active.clone()] + } + Err(WalkError::Backend(e)) => { + tracing::error!(error = %e, "tenant_scope: backend error during hierarchy walk"); + return internal_error("backend error during tenant hierarchy walk"); + } + }; + + let mut new_claims = claims; + match serde_json::to_value(&effective) { + Ok(v) => { + new_claims.custom.insert("tenant_chain".to_string(), v); + } + Err(e) => { + tracing::error!(error = %e, "tenant_scope: failed to serialize effective chain"); + return internal_error("failed to serialize effective tenant chain"); + } + } + request.extensions_mut().insert(new_claims); + + next.run(request.into()).await +} + +/// Per-request refusal that must short-circuit before the inner handler +/// runs. Returned by [`select_active_tenant`]. +#[derive(Debug)] +enum TenantScopeRefusal { + /// User has multiple memberships and did not supply `X-Active-Tenant`. + MissingActiveTenant, + /// `X-Active-Tenant` value is malformed (not `:`). + InvalidActiveTenant, + /// `X-Active-Tenant` references a tenant the user is not a member of. + NotInMemberships, +} + +impl IntoResponse for TenantScopeRefusal { + fn into_response(self) -> Response { + match self { + Self::MissingActiveTenant => ( + StatusCode::BAD_REQUEST, + Json(TenantScopeError { + error: "X-Active-Tenant required: user has multiple tenant memberships", + code: "ACTIVE_TENANT_REQUIRED", + status: 400, + }), + ) + .into_response(), + Self::InvalidActiveTenant => ( + StatusCode::BAD_REQUEST, + Json(TenantScopeError { + error: "X-Active-Tenant must be of the form :", + code: "ACTIVE_TENANT_INVALID", + status: 400, + }), + ) + .into_response(), + Self::NotInMemberships => ( + StatusCode::FORBIDDEN, + Json(TenantScopeError { + error: "X-Active-Tenant not in user memberships", + code: "ACTIVE_TENANT_FORBIDDEN", + status: 403, + }), + ) + .into_response(), + } + } +} + +fn internal_error(message: &'static str) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "internal_error", + "message": message, + })), + ) + .into_response() +} + +/// Decide which membership scopes this request. Pure function over the +/// request's `X-Active-Tenant` header and the user's membership set. +fn select_active_tenant<'a, B>( + request: &Request, + memberships: &'a [TenantRef], +) -> Result<&'a TenantRef, TenantScopeRefusal> { + debug_assert!( + !memberships.is_empty(), + "select_active_tenant called with empty memberships" + ); + let header = request + .headers() + .get(ACTIVE_TENANT_HEADER) + .and_then(|v| v.to_str().ok()); + + let Some(header_value) = header else { + if memberships.len() == 1 { + return Ok(&memberships[0]); + } + return Err(TenantScopeRefusal::MissingActiveTenant); + }; + + let parsed = parse_active_tenant(header_value) + .ok_or(TenantScopeRefusal::InvalidActiveTenant)?; + memberships + .iter() + .find(|m| m.schema == parsed.0 && m.entity_id == parsed.1) + .ok_or(TenantScopeRefusal::NotInMemberships) +} + +/// Parse a `:` header value. +/// +/// Returns `None` for any malformed input. The entity_id half may itself +/// contain colons (TypeIDs use `_`, not `:`, but we don't enforce that +/// here — only the first `:` is treated as the separator). +fn parse_active_tenant(s: &str) -> Option<(String, String)> { + let (schema, rest) = s.split_once(':')?; + if schema.is_empty() || rest.is_empty() { + return None; + } + Some((schema.to_string(), rest.to_string())) +} + +/// Errors raised during the hierarchy walk. +#[derive(Debug)] +enum WalkError { + /// A leaf or intermediate entity couldn't be found. Treated as + /// non-fatal by the caller — the effective chain collapses to just + /// the active leaf. + EntityMissing { + schema: String, + entity_id: String, + }, + /// Backend errored on a `get`. Treated as fatal (500). + Backend(schema_forge_backend::error::BackendError), +} + +impl std::fmt::Display for WalkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EntityMissing { schema, entity_id } => { + write!(f, "entity {schema}/{entity_id} missing during walk") + } + Self::Backend(e) => write!(f, "{e}"), + } + } +} + +/// Walk the tenant hierarchy from `leaf` up to the root, returning the +/// effective chain in root→leaf order. +/// +/// At each step, looks up the level's `parent_field` from `TenantConfig` +/// then fetches the current entity to read the referenced parent id. If +/// the active leaf's schema isn't in the hierarchy at all (stale +/// membership row pointing to a non-tenant schema), returns just the +/// leaf — the request stays servable but won't unlock anything above +/// the leaf in Cedar's parent set. +async fn walk_to_root( + leaf: &TenantRef, + tenant_config: &TenantConfig, + store: &dyn DynEntityStore, +) -> Result, WalkError> { + let mut chain: Vec = vec![leaf.clone()]; + + let mut current_schema = leaf.schema.clone(); + let mut current_id = leaf.entity_id.clone(); + + loop { + // Look up the current level in TenantConfig. If the active leaf's + // schema isn't a registered tenant level, there's nothing to walk + // — return what we have. + let Some(level) = tenant_config + .hierarchy + .iter() + .find(|l| l.schema.as_str() == current_schema.as_str()) + else { + break; + }; + + // Reached the root: no parent to walk to. + let (Some(parent_schema), Some(parent_field)) = (&level.parent, &level.parent_field) else { + break; + }; + + // Fetch the current entity to read its parent reference field. + let schema_name = SchemaName::new(¤t_schema).map_err(|_| WalkError::EntityMissing { + schema: current_schema.clone(), + entity_id: current_id.clone(), + })?; + let entity_id = + schema_forge_core::types::EntityId::parse(¤t_id).map_err(|_| WalkError::EntityMissing { + schema: current_schema.clone(), + entity_id: current_id.clone(), + })?; + + let entity = match store.get(&schema_name, &entity_id).await { + Ok(e) => e, + Err(schema_forge_backend::error::BackendError::EntityNotFound { .. }) => { + return Err(WalkError::EntityMissing { + schema: current_schema, + entity_id: current_id, + }); + } + Err(e) => return Err(WalkError::Backend(e)), + }; + + let parent_value = entity.field(parent_field.as_str()); + let parent_id = match parent_value { + Some(DynamicValue::Ref(id)) => id.as_str().to_string(), + // Composite or missing — can't walk further. The chain is + // capped at what we've collected so far. + _ => break, + }; + + let parent_ref = TenantRef { + schema: parent_schema.as_str().to_string(), + entity_id: parent_id.clone(), + }; + chain.push(parent_ref); + + current_schema = parent_schema.as_str().to_string(); + current_id = parent_id; + } + + // Caller wants root→leaf order; reverse so the deepest tenant lands at + // `chain.last()` (matches the historic naming the old code used). + chain.reverse(); + Ok(chain) +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + + fn membership(schema: &str, id: &str) -> TenantRef { + TenantRef { + schema: schema.to_string(), + entity_id: id.to_string(), + } + } + + fn req(headers: &[(&str, &str)]) -> Request { + let mut builder = Request::builder().uri("/api/v1/forge/schemas/Foo/entities"); + for (k, v) in headers { + builder = builder.header(*k, *v); + } + builder.body(Body::empty()).unwrap() + } + + #[test] + fn parse_active_tenant_accepts_well_formed_pair() { + let (s, id) = parse_active_tenant("Organization:org-a").unwrap(); + assert_eq!(s, "Organization"); + assert_eq!(id, "org-a"); + } + + #[test] + fn parse_active_tenant_rejects_missing_separator() { + assert!(parse_active_tenant("Organization-org-a").is_none()); + } + + #[test] + fn parse_active_tenant_rejects_empty_halves() { + assert!(parse_active_tenant(":org-a").is_none()); + assert!(parse_active_tenant("Organization:").is_none()); + assert!(parse_active_tenant(":").is_none()); + } + + #[test] + fn select_active_tenant_uses_sole_membership_when_header_absent() { + let memberships = vec![membership("Organization", "org-a")]; + let request = req(&[]); + let active = select_active_tenant(&request, &memberships).unwrap(); + assert_eq!(active.entity_id, "org-a"); + } + + #[test] + fn select_active_tenant_refuses_when_header_missing_and_multi_membership() { + let memberships = vec![ + membership("Organization", "org-a"), + membership("Organization", "org-b"), + ]; + let request = req(&[]); + let err = select_active_tenant(&request, &memberships).unwrap_err(); + assert!(matches!(err, TenantScopeRefusal::MissingActiveTenant)); + } + + #[test] + fn select_active_tenant_uses_header_when_present_and_valid() { + let memberships = vec![ + membership("Organization", "org-a"), + membership("Organization", "org-b"), + ]; + let request = req(&[("x-active-tenant", "Organization:org-b")]); + let active = select_active_tenant(&request, &memberships).unwrap(); + assert_eq!(active.entity_id, "org-b"); + } + + #[test] + fn select_active_tenant_refuses_header_not_in_memberships() { + let memberships = vec![membership("Organization", "org-a")]; + let request = req(&[("x-active-tenant", "Organization:org-b")]); + let err = select_active_tenant(&request, &memberships).unwrap_err(); + assert!(matches!(err, TenantScopeRefusal::NotInMemberships)); + } + + #[test] + fn select_active_tenant_refuses_malformed_header() { + let memberships = vec![ + membership("Organization", "org-a"), + membership("Organization", "org-b"), + ]; + let request = req(&[("x-active-tenant", "garbage-no-colon")]); + let err = select_active_tenant(&request, &memberships).unwrap_err(); + assert!(matches!(err, TenantScopeRefusal::InvalidActiveTenant)); + } + + /// Build a `TenantConfig` for [Organization (root), Department (child of Organization)] + /// and a `MemStore` containing a single Department row whose `organization` + /// ref points at "Organization::org-a". + async fn config_with_org_dept_hierarchy() -> (TenantConfig, Arc) { + use crate::middleware::tenant_scope::test_support::MemStore; + use schema_forge_backend::Entity; + use schema_forge_core::types::{ + Annotation, FieldDefinition, FieldModifier, FieldName, FieldType, SchemaId, + SchemaName as CoreSchemaName, TenantKind, TextConstraints, + }; + use schema_forge_core::types::SchemaDefinition; + + let org_schema = SchemaDefinition::new( + SchemaId::new(), + CoreSchemaName::new("Organization").unwrap(), + vec![FieldDefinition::with_annotations( + FieldName::new("name").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + )], + vec![Annotation::Tenant(TenantKind::Root)], + ) + .unwrap(); + + let dept_schema = SchemaDefinition::new( + SchemaId::new(), + CoreSchemaName::new("Department").unwrap(), + vec![ + FieldDefinition::with_annotations( + FieldName::new("name").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + ), + FieldDefinition::with_annotations( + FieldName::new("organization").unwrap(), + FieldType::Relation { + target: CoreSchemaName::new("Organization").unwrap(), + cardinality: schema_forge_core::types::Cardinality::One, + }, + vec![FieldModifier::Required], + vec![], + ), + ], + vec![Annotation::Tenant(TenantKind::Child { + parent: CoreSchemaName::new("Organization").unwrap(), + })], + ) + .unwrap(); + + let cfg = TenantConfig::from_schemas(&[org_schema.clone(), dept_schema.clone()]).unwrap(); + assert!(cfg.is_enabled()); + assert_eq!(cfg.hierarchy.len(), 2); + + let store = Arc::new(MemStore::new()); + + // Seed a Department row with `organization` -> Ref(). + // The walk parses Department's `current_id` via EntityId::parse, so we + // mint a real EntityId for both the department and the organization. + let org_id = schema_forge_core::types::EntityId::new("Organization"); + let dept_id = schema_forge_core::types::EntityId::new("Department"); + + use schema_forge_backend::traits::EntityStore; + let mut org_fields: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + org_fields.insert("name".into(), DynamicValue::Text("Org A".into())); + EntityStore::create( + store.as_ref(), + &Entity::with_id(org_id.clone(), org_schema.name.clone(), org_fields), + ) + .await + .unwrap(); + + let mut dept_fields: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + dept_fields.insert("name".into(), DynamicValue::Text("Dept 1".into())); + dept_fields.insert("organization".into(), DynamicValue::Ref(org_id.clone())); + EntityStore::create( + store.as_ref(), + &Entity::with_id(dept_id.clone(), dept_schema.name.clone(), dept_fields), + ) + .await + .unwrap(); + + // Stash the ids in the store so the test can read them back. + store.set_seed_ids(org_id.as_str(), dept_id.as_str()); + + (cfg, store) + } + + #[tokio::test] + async fn walk_to_root_returns_leaf_when_schema_not_in_hierarchy() { + use crate::middleware::tenant_scope::test_support::MemStore; + let cfg = TenantConfig::from_schemas(&[]).unwrap(); + let store = Arc::new(MemStore::new()); + let leaf = membership("Random", "rnd-1"); + let walk = walk_to_root(&leaf, &cfg, store.as_ref()).await.unwrap(); + assert_eq!(walk, vec![leaf]); + } + + #[tokio::test] + async fn walk_to_root_walks_two_level_hierarchy() { + let (cfg, store) = config_with_org_dept_hierarchy().await; + let (org_id, dept_id) = store.seed_ids(); + let leaf = membership("Department", &dept_id); + let walk = walk_to_root(&leaf, &cfg, store.as_ref()).await.unwrap(); + // root→leaf order: Organization first, Department last. + assert_eq!(walk.len(), 2); + assert_eq!(walk[0].schema, "Organization"); + assert_eq!(walk[0].entity_id, org_id); + assert_eq!(walk[1].schema, "Department"); + assert_eq!(walk[1].entity_id, dept_id); + } + + #[tokio::test] + async fn walk_to_root_at_root_returns_just_root() { + let (cfg, store) = config_with_org_dept_hierarchy().await; + let (org_id, _) = store.seed_ids(); + let leaf = membership("Organization", &org_id); + let walk = walk_to_root(&leaf, &cfg, store.as_ref()).await.unwrap(); + assert_eq!(walk.len(), 1); + assert_eq!(walk[0].entity_id, org_id); + } +} + +#[cfg(test)] +mod test_support { + //! Tiny in-memory `EntityStore`/`DynEntityStore` for the tenant-scope + //! middleware tests. Kept separate from the test module so the hierarchy + //! walk's helpers can name the type by path. + use std::collections::BTreeMap; + use std::sync::Mutex; + + use schema_forge_backend::entity::{Entity, QueryResult}; + use schema_forge_backend::error::BackendError; + use schema_forge_backend::traits::EntityStore; + use schema_forge_core::query::Query; + use schema_forge_core::types::{EntityId, SchemaName}; + + #[derive(Default)] + pub struct MemStore { + rows: Mutex>, + seed_ids: Mutex<(String, String)>, + } + + impl MemStore { + pub fn new() -> Self { + Self::default() + } + + pub fn set_seed_ids(&self, org: &str, dept: &str) { + *self.seed_ids.lock().unwrap() = (org.to_string(), dept.to_string()); + } + + pub fn seed_ids(&self) -> (String, String) { + self.seed_ids.lock().unwrap().clone() + } + } + + impl EntityStore for MemStore { + fn create( + &self, + entity: &Entity, + ) -> impl std::future::Future> + Send { + let entity = entity.clone(); + async move { + self.rows.lock().unwrap().push(entity.clone()); + Ok(entity) + } + } + + fn get( + &self, + schema: &SchemaName, + id: &EntityId, + ) -> impl std::future::Future> + Send { + let schema = schema.as_str().to_string(); + let id = id.clone(); + async move { + self.rows + .lock() + .unwrap() + .iter() + .find(|e| e.id == id) + .cloned() + .ok_or(BackendError::EntityNotFound { + schema, + entity_id: id.as_str().to_string(), + }) + } + } + + fn update( + &self, + entity: &Entity, + ) -> impl std::future::Future> + Send { + let entity = entity.clone(); + async move { + let mut rows = self.rows.lock().unwrap(); + if let Some(slot) = rows.iter_mut().find(|e| e.id == entity.id) { + *slot = entity.clone(); + Ok(entity) + } else { + Err(BackendError::EntityNotFound { + schema: entity.schema.as_str().to_string(), + entity_id: entity.id.as_str().to_string(), + }) + } + } + } + + fn delete( + &self, + _schema: &SchemaName, + id: &EntityId, + ) -> impl std::future::Future> + Send { + let id = id.clone(); + async move { + self.rows.lock().unwrap().retain(|e| e.id != id); + Ok(()) + } + } + + async fn query(&self, _query: &Query) -> Result { + Ok(QueryResult::new(Vec::new(), Some(0))) + } + + async fn count(&self, _query: &Query) -> Result { + Ok(0) + } + + async fn aggregate( + &self, + _query: &schema_forge_core::query::AggregateQuery, + ) -> Result, BackendError> { + Ok(Vec::new()) + } + } + + // Helper to let the middleware test module pass `Arc` where an + // `&dyn DynEntityStore` is expected: the blanket impl of DynEntityStore + // for any EntityStore does the heavy lifting; we just need the concrete + // type to be reachable from the test module by path. + static _ASSERT_MEMSTORE_USABLE: fn() = || { + let _: &dyn schema_forge_backend::DynEntityStore = &MemStore::new(); + }; + + // Used by the unused-import lint to avoid removing the BTreeMap import + // for the public `Entity::new` arms our test paths exercise. + fn _force_btreemap_use(_: BTreeMap) {} +} diff --git a/crates/schema-forge-acton/src/routes/auth.rs b/crates/schema-forge-acton/src/routes/auth.rs index 38fc247..fd14c47 100644 --- a/crates/schema-forge-acton/src/routes/auth.rs +++ b/crates/schema-forge-acton/src/routes/auth.rs @@ -25,10 +25,11 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use chrono::Utc; +use schema_forge_backend::tenant::{TenantConfig, TenantRef}; use schema_forge_backend::Entity; use serde::{Deserialize, Serialize}; -use crate::access::OptionalClaims; +use crate::access::{OptionalClaims, PLATFORM_ADMIN_ROLE}; use crate::authz::principal_claims::{PrincipalClaimMappings, PrincipalClaimsError}; use crate::config::SchemaForgeConfig; use crate::state::DynAuthStore; @@ -101,6 +102,7 @@ pub async fn login( Extension(auth_store): Extension>, Extension(generator): Extension>, Extension(principal_claims): Extension>, + Extension(tenant_config): Extension>>, Json(req): Json, ) -> Response { let user = match auth_store @@ -131,15 +133,34 @@ pub async fn login( None }; - let claims = - match build_login_claims(&user.username, &user.roles, user_entity.as_ref(), &principal_claims) { - Ok(c) => c, - Err(BuildLoginClaimsError::NullRequired(_)) => { - emit_login_failed(&state, &req.username).await; - return unauthorized_response(); - } - Err(e) => return internal_error_response(format!("failed to build claims: {e}")), - }; + let memberships = match auth_store.list_tenant_memberships(&user.username).await { + Ok(m) => m, + Err(e) => return internal_error_response(format!("auth store error: {e}")), + }; + + if let Err(refusal) = enforce_tenant_membership_policy( + &memberships, + &user.roles, + tenant_config.as_ref().as_ref(), + ) { + emit_login_failed(&state, &req.username).await; + return refusal.into_response(); + } + + let claims = match build_login_claims( + &user.username, + &user.roles, + user_entity.as_ref(), + &principal_claims, + &memberships, + ) { + Ok(c) => c, + Err(BuildLoginClaimsError::NullRequired(_)) => { + emit_login_failed(&state, &req.username).await; + return unauthorized_response(); + } + Err(e) => return internal_error_response(format!("failed to build claims: {e}")), + }; let token = match generator.generate_token_with_expiry(&claims, LOGIN_TOKEN_LIFETIME) { Ok(t) => t, @@ -183,6 +204,7 @@ pub async fn refresh( Extension(auth_store): Extension>, Extension(generator): Extension>, Extension(principal_claims): Extension>, + Extension(tenant_config): Extension>>, ) -> Response { let Some(claims) = claims else { return unauthorized_response(); @@ -197,7 +219,8 @@ pub async fn refresh( // mutated since the original login (e.g., role change, client_org // reassignment) takes effect immediately on the next refresh, which is // load-bearing for per-record scoping that depends on `principal.*` - // attributes derived from User columns. + // attributes derived from User columns. Same contract for memberships + // below: a grant or revocation lands on the next refresh. let user = match auth_store.get_user(&username).await { Ok(Some(u)) if u.active => u, Ok(_) => return unauthorized_response(), @@ -214,12 +237,30 @@ pub async fn refresh( None }; - let next_claims = - match build_login_claims(&user.username, &user.roles, user_entity.as_ref(), &principal_claims) { - Ok(c) => c, - Err(BuildLoginClaimsError::NullRequired(_)) => return unauthorized_response(), - Err(e) => return internal_error_response(format!("failed to build claims: {e}")), - }; + let memberships = match auth_store.list_tenant_memberships(&user.username).await { + Ok(m) => m, + Err(e) => return internal_error_response(format!("auth store error: {e}")), + }; + + if let Err(refusal) = enforce_tenant_membership_policy( + &memberships, + &user.roles, + tenant_config.as_ref().as_ref(), + ) { + return refusal.into_response(); + } + + let next_claims = match build_login_claims( + &user.username, + &user.roles, + user_entity.as_ref(), + &principal_claims, + &memberships, + ) { + Ok(c) => c, + Err(BuildLoginClaimsError::NullRequired(_)) => return unauthorized_response(), + Err(e) => return internal_error_response(format!("failed to build claims: {e}")), + }; let token = match generator.generate_token_with_expiry(&next_claims, LOGIN_TOKEN_LIFETIME) { Ok(t) => t, @@ -305,6 +346,7 @@ pub(crate) fn build_login_claims( roles: &[String], user_entity: Option<&Entity>, principal_claims: &PrincipalClaimMappings, + memberships: &[TenantRef], ) -> Result { let mut builder = ClaimsBuilder::new().user(username).username(username); for role in roles { @@ -327,9 +369,77 @@ pub(crate) fn build_login_claims( } } + // Project the user's flat tenant-membership set into the token. The + // claim name stays `tenant_chain` for backward compatibility with the + // existing Cedar adapter and access.rs reads, but it now carries the + // user's full membership set — *not* a hierarchy walk. The active + // tenant + ancestor chain is materialized per-request by the + // `tenant_scope` middleware before downstream handlers see Claims. + if !memberships.is_empty() { + let value = serde_json::to_value(memberships) + .map_err(BuildLoginClaimsError::MembershipSerialize)?; + builder = builder.custom_claim("tenant_chain", value); + } + builder.build().map_err(BuildLoginClaimsError::Acton) } +/// Decide whether login should proceed given the user's membership set, +/// roles, and the deployment's tenant configuration. +/// +/// Rules: +/// - Tenancy not configured (`tenant_config` is `None` or disabled) → always +/// `Ok(())`. Single-tenant deployments are unaffected. +/// - `platform_admin` in roles → always `Ok(())`. The platform-admin role +/// transcends tenancy; this matches the existing access.rs bypass. +/// - 0 memberships under enabled tenancy → `Err(NoTenantAssigned)`. Closes +/// the "user logs in and sees every tenant" hole hard. Operators must +/// explicitly grant a `TenantMembership` row before the user can hit the +/// API. +/// - 1+ memberships → `Ok(())`. +pub(crate) fn enforce_tenant_membership_policy( + memberships: &[TenantRef], + roles: &[String], + tenant_config: Option<&TenantConfig>, +) -> Result<(), LoginRefusal> { + let tenancy_enabled = tenant_config.is_some_and(TenantConfig::is_enabled); + if !tenancy_enabled { + return Ok(()); + } + if roles.iter().any(|r| r == PLATFORM_ADMIN_ROLE) { + return Ok(()); + } + if memberships.is_empty() { + return Err(LoginRefusal::NoTenantAssigned); + } + Ok(()) +} + +/// A login or refresh request that must be refused before token mint. +/// +/// Separate from [`BuildLoginClaimsError`] so callers can distinguish a +/// policy refusal (user-actionable: contact admin to grant a membership) +/// from an internal failure (operator-actionable: misconfigured store). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LoginRefusal { + NoTenantAssigned, +} + +impl IntoResponse for LoginRefusal { + fn into_response(self) -> Response { + match self { + Self::NoTenantAssigned => { + let body = LoginErrorBody { + error: "no tenant assigned", + code: "UNAUTHORIZED", + status: 401, + }; + (StatusCode::UNAUTHORIZED, Json(body)).into_response() + } + } + } +} + /// Errors raised while building PASETO claims for login or refresh. #[derive(Debug)] pub(crate) enum BuildLoginClaimsError { @@ -340,6 +450,11 @@ pub(crate) enum BuildLoginClaimsError { NullRequired(PrincipalClaimsError), /// Other principal-claim projection failure (e.g. type mismatch at runtime). PrincipalClaim(PrincipalClaimsError), + /// Serializing the membership set into the PASETO custom-claim JSON + /// failed. `TenantRef` derives Serialize so this is effectively unreachable + /// in practice, but kept as a distinct variant so the handler returns a + /// 500 (server error) rather than a 401 (user error). + MembershipSerialize(serde_json::Error), /// `acton-service` failed to assemble the claim envelope. Acton(ActonError), } @@ -351,6 +466,7 @@ impl fmt::Display for BuildLoginClaimsError { "principal-claim sources are configured but no user entity row was provided", ), Self::NullRequired(e) | Self::PrincipalClaim(e) => write!(f, "{e}"), + Self::MembershipSerialize(e) => write!(f, "tenant_chain serialize failed: {e}"), Self::Acton(e) => write!(f, "{e}"), } } @@ -361,6 +477,7 @@ impl std::error::Error for BuildLoginClaimsError { match self { Self::MissingUserEntity => None, Self::NullRequired(e) | Self::PrincipalClaim(e) => Some(e), + Self::MembershipSerialize(e) => Some(e), Self::Acton(e) => Some(e), } } @@ -416,6 +533,7 @@ mod tests { &["admin".to_string(), "hr".to_string()], None, &mappings, + &[], ) .unwrap(); assert_eq!(claims.sub, "user:alice"); @@ -427,8 +545,120 @@ mod tests { #[test] fn build_login_claims_with_no_roles() { let mappings = PrincipalClaimMappings::default(); - let claims = build_login_claims("bob", &[], None, &mappings).unwrap(); + let claims = build_login_claims("bob", &[], None, &mappings, &[]).unwrap(); assert_eq!(claims.sub, "user:bob"); assert!(claims.roles.is_empty()); } + + #[test] + fn build_login_claims_with_no_memberships_omits_tenant_chain() { + let mappings = PrincipalClaimMappings::default(); + let claims = build_login_claims("alice", &[], None, &mappings, &[]).unwrap(); + // The custom map exists, but no `tenant_chain` key should be set. + assert!( + claims + .custom_claim_as::>("tenant_chain") + .is_none(), + "expected no tenant_chain claim when memberships is empty" + ); + } + + #[test] + fn build_login_claims_projects_memberships_into_tenant_chain() { + let mappings = PrincipalClaimMappings::default(); + let memberships = vec![ + TenantRef { + schema: "Organization".to_string(), + entity_id: "org-a".to_string(), + }, + TenantRef { + schema: "Organization".to_string(), + entity_id: "org-b".to_string(), + }, + ]; + let claims = + build_login_claims("alice", &[], None, &mappings, &memberships).unwrap(); + let chain = claims + .custom_claim_as::>("tenant_chain") + .expect("tenant_chain populated"); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0].entity_id, "org-a"); + assert_eq!(chain[1].entity_id, "org-b"); + } + + #[test] + fn enforce_policy_no_tenancy_always_allows() { + let res = enforce_tenant_membership_policy(&[], &[], None); + assert!(res.is_ok()); + } + + #[test] + fn enforce_policy_platform_admin_bypasses_when_tenancy_enabled() { + use schema_forge_core::types::{ + Annotation, FieldDefinition, FieldModifier, FieldName, FieldType, SchemaId, + SchemaName, TenantKind, TextConstraints, + }; + use schema_forge_core::types::SchemaDefinition; + // Minimal @tenant(root) schema so TenantConfig::is_enabled() is true. + let root = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Organization").unwrap(), + vec![FieldDefinition::with_annotations( + FieldName::new("name").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + )], + vec![Annotation::Tenant(TenantKind::Root)], + ) + .unwrap(); + let cfg = TenantConfig::from_schemas(&[root]).unwrap(); + assert!(cfg.is_enabled()); + + let res = enforce_tenant_membership_policy( + &[], + &[PLATFORM_ADMIN_ROLE.to_string()], + Some(&cfg), + ); + assert!(res.is_ok(), "platform_admin must bypass zero-membership refusal"); + } + + #[test] + fn enforce_policy_zero_membership_with_enabled_tenancy_refuses() { + use schema_forge_core::types::{ + Annotation, FieldDefinition, FieldModifier, FieldName, FieldType, SchemaId, + SchemaName, TenantKind, TextConstraints, + }; + use schema_forge_core::types::SchemaDefinition; + let root = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Organization").unwrap(), + vec![FieldDefinition::with_annotations( + FieldName::new("name").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + )], + vec![Annotation::Tenant(TenantKind::Root)], + ) + .unwrap(); + let cfg = TenantConfig::from_schemas(&[root]).unwrap(); + + let res = enforce_tenant_membership_policy( + &[], + &["member".to_string()], + Some(&cfg), + ); + assert_eq!(res, Err(LoginRefusal::NoTenantAssigned)); + } + + #[test] + fn enforce_policy_membership_present_allows() { + let memberships = vec![TenantRef { + schema: "Organization".to_string(), + entity_id: "org-a".to_string(), + }]; + let res = enforce_tenant_membership_policy(&memberships, &[], None); + assert!(res.is_ok()); + } } diff --git a/crates/schema-forge-acton/src/shared_auth.rs b/crates/schema-forge-acton/src/shared_auth.rs index b0cfca1..4b268b3 100644 --- a/crates/schema-forge-acton/src/shared_auth.rs +++ b/crates/schema-forge-acton/src/shared_auth.rs @@ -249,6 +249,13 @@ mod tests { ) -> Result<(), BackendError> { unimplemented!("not used by bootstrap path") } + + async fn list_tenant_memberships( + &self, + _username: &str, + ) -> Result, BackendError> { + unimplemented!("not used by bootstrap path") + } } /// Helper: run an async block on a single-threaded current-thread runtime. diff --git a/crates/schema-forge-acton/src/state.rs b/crates/schema-forge-acton/src/state.rs index f33bf13..55672b8 100644 --- a/crates/schema-forge-acton/src/state.rs +++ b/crates/schema-forge-acton/src/state.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use schema_forge_backend::entity::{Entity, QueryResult}; use schema_forge_backend::error::BackendError; +use schema_forge_backend::tenant::TenantRef; use schema_forge_backend::traits::{EntityStore, SchemaBackend}; use schema_forge_backend::user_store::{AuthStore, ForgeUser}; use schema_forge_core::migration::MigrationStep; @@ -304,6 +305,13 @@ pub trait DynAuthStore: Send + Sync { username: &'a str, at: DateTime, ) -> Pin> + Send + 'a>>; + + /// List the user's tenant memberships. See + /// [`schema_forge_backend::user_store::AuthStore::list_tenant_memberships`]. + fn list_tenant_memberships<'a>( + &'a self, + username: &'a str, + ) -> Pin, BackendError>> + Send + 'a>>; } /// Blanket impl: any concrete `AuthStore` automatically implements `DynAuthStore`. @@ -396,6 +404,13 @@ impl DynAuthStore for T { ) -> Pin> + Send + 'a>> { Box::pin(AuthStore::record_login(self, username, at)) } + + fn list_tenant_memberships<'a>( + &'a self, + username: &'a str, + ) -> Pin, BackendError>> + Send + 'a>> { + Box::pin(AuthStore::list_tenant_memberships(self, username)) + } } // --------------------------------------------------------------------------- diff --git a/crates/schema-forge-acton/tests/auth_login.rs b/crates/schema-forge-acton/tests/auth_login.rs index a68ffce..3184a88 100644 --- a/crates/schema-forge-acton/tests/auth_login.rs +++ b/crates/schema-forge-acton/tests/auth_login.rs @@ -103,15 +103,33 @@ fn build_test_generator() -> (Arc, NamedTempFile) { /// Build a router that mounts only `/auth/login` with the Extensions the /// login handler now depends on. The auth store is returned so tests can /// read back the User row to verify side-effects (e.g. `last_login`). +/// +/// `tenant_config` is `None` (tenancy disabled) — the basic suite covers +/// the historical single-tenant path. Tenancy-enabled tests use +/// [`login_app_with_tenancy`] instead. async fn login_app() -> (Router, NamedTempFile, Arc) { - let auth_store = seeded_auth_store().await; + let (router, key_tmp, store) = login_app_with(seeded_auth_store().await, None).await; + (router, key_tmp, store) +} + +/// Variant of [`login_app`] that lets the caller supply both a pre-seeded +/// auth store and an optional `TenantConfig`. Used by the multi-tenant +/// tests to exercise the zero-membership refusal and tenant_chain +/// projection paths. +async fn login_app_with( + auth_store: Arc, + tenant_config: Option, +) -> (Router, NamedTempFile, Arc) { let (generator, key_tmp) = build_test_generator(); let principal_claims = Arc::new(schema_forge_acton::authz::PrincipalClaimMappings::default()); + let tenant_layer: Arc> = + Arc::new(tenant_config); let router = auth_routes() .layer(Extension(auth_store.clone())) .layer(Extension(generator)) .layer(Extension(principal_claims)) + .layer(Extension(tenant_layer)) .with_state(acton_service::state::AppState::< schema_forge_acton::SchemaForgeConfig, >::default()); @@ -209,6 +227,203 @@ async fn login_success_stamps_last_login_on_user_row() { ); } +// --------------------------------------------------------------------------- +// Tenancy / TenantMembership tests (issue #67) +// --------------------------------------------------------------------------- + +/// Seed an in-memory backend with both the User and TenantMembership system +/// schemas migrated, and one user "alice" with `n_memberships` rows pointing +/// at distinct Organization IDs. +async fn seeded_auth_store_with_memberships( + n_memberships: usize, + roles: &[&str], +) -> (Arc, schema_forge_backend::tenant::TenantConfig) { + use schema_forge_backend::traits::SchemaBackend; + use schema_forge_backend::{DynEntityStore, Entity}; + use schema_forge_core::migration::DiffEngine; + use schema_forge_core::types::DynamicValue; + + let backend = SurrealBackend::connect_memory("test", "auth_login_tenancy_test") + .await + .expect("connect in-memory surreal"); + + // Migrate User schema. + let user_schema = parse_user_schema(); + let plan = DiffEngine::create_new(&user_schema); + backend + .apply_migration(&user_schema.name, &plan.steps) + .await + .expect("apply User migration"); + backend + .store_schema_metadata(&user_schema) + .await + .expect("store User schema metadata"); + + // Migrate TenantMembership schema. + let mut parsed_tm = schema_forge_dsl::parse( + schema_forge_core::system_schemas::TENANT_MEMBERSHIP_SCHEMA, + ) + .expect("TENANT_MEMBERSHIP_SCHEMA parses"); + let tm_schema = parsed_tm.pop().expect("one TenantMembership schema"); + let tm_plan = DiffEngine::create_new(&tm_schema); + backend + .apply_migration(&tm_schema.name, &tm_plan.steps) + .await + .expect("apply TenantMembership migration"); + backend + .store_schema_metadata(&tm_schema) + .await + .expect("store TenantMembership schema metadata"); + + // Build a minimal Organization schema marked @tenant(root) so the + // TenantConfig we hand to the login handler reports tenancy enabled. + use schema_forge_core::types::{ + Annotation, FieldDefinition, FieldModifier, FieldName, FieldType, SchemaId, + SchemaName as CoreSchemaName, TenantKind, TextConstraints, + }; + let org_schema = SchemaDefinition::new( + SchemaId::new(), + CoreSchemaName::new("Organization").unwrap(), + vec![FieldDefinition::with_annotations( + FieldName::new("name").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + )], + vec![Annotation::Tenant(TenantKind::Root)], + ) + .unwrap(); + + let tenant_config = schema_forge_backend::tenant::TenantConfig::from_schemas( + std::slice::from_ref(&org_schema), + ) + .unwrap(); + assert!(tenant_config.is_enabled()); + + let backend = Arc::new(backend); + let entity_store: Arc = backend.clone(); + + let resolver: schema_forge_backend::entity_auth_store::RoleRankResolver = + Arc::new(|_role: &str| None); + let store = EntityAuthStore::new(entity_store.clone(), user_schema, resolver) + .with_tenant_membership_schema(tm_schema.clone()); + + let role_strings: Vec = roles.iter().map(|r| r.to_string()).collect(); + AuthStore::create_user(&store, "alice", "dev", &role_strings, "Alice") + .await + .expect("seed alice user"); + + // Fetch alice's EntityId so we can write TenantMembership refs. + let alice = AuthStore::get_user_entity(&store, "alice") + .await + .unwrap() + .unwrap(); + + for i in 0..n_memberships { + let mut fields: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + fields.insert("user".into(), DynamicValue::Ref(alice.id.clone())); + fields.insert( + "tenant_type".into(), + DynamicValue::Text("Organization".to_string()), + ); + fields.insert( + "tenant_id".into(), + DynamicValue::Text(format!("org-{}", (b'a' + i as u8) as char)), + ); + let row = Entity::new(tm_schema.name.clone(), fields); + // DynEntityStore::create returns a boxed future; call it via the + // trait method directly so we don't need a concrete EntityStore impl. + entity_store + .create(&row) + .await + .expect("seed TenantMembership row"); + } + + (Arc::new(store), tenant_config) +} + +/// Decode a minted PASETO token to inspect its `tenant_chain` custom claim. +/// +/// The login handler uses a generator built by [`build_test_generator`] +/// against a deterministic 32-byte key; this function builds a matching +/// `PasetoAuth` validator over the same key file so we can round-trip +/// the token without standing up the full middleware stack. +fn decode_tenant_chain( + token: &str, + key_path: &std::path::Path, +) -> Vec { + use acton_service::config::PasetoConfig; + use acton_service::middleware::{PasetoAuth, TokenValidator}; + let cfg = PasetoConfig { + version: "v4".into(), + purpose: "local".into(), + key_path: key_path.to_path_buf(), + issuer: Some("schemaforge-test".into()), + audience: None, + public_paths: Vec::new(), + }; + let auth = PasetoAuth::new(&cfg).expect("build PasetoAuth"); + let claims = auth.validate_token(token).expect("token validates"); + claims + .custom_claim_as::>("tenant_chain") + .unwrap_or_default() +} + +#[tokio::test] +async fn login_emits_tenant_chain_for_single_membership_user() { + let (store, tenant_config) = + seeded_auth_store_with_memberships(1, &["member"]).await; + let (app, key_tmp, _store) = login_app_with(store, Some(tenant_config)).await; + let (status, body) = post_login(app, r#"{"username":"alice","password":"dev"}"#).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let token = body["token"].as_str().expect("token field is a string"); + let chain = decode_tenant_chain(token, key_tmp.path()); + assert_eq!(chain.len(), 1); + assert_eq!(chain[0].schema, "Organization"); + assert_eq!(chain[0].entity_id, "org-a"); +} + +#[tokio::test] +async fn login_emits_full_membership_set_for_multi_membership_user() { + let (store, tenant_config) = + seeded_auth_store_with_memberships(2, &["member"]).await; + let (app, key_tmp, _store) = login_app_with(store, Some(tenant_config)).await; + let (status, body) = post_login(app, r#"{"username":"alice","password":"dev"}"#).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let token = body["token"].as_str().expect("token field is a string"); + let chain = decode_tenant_chain(token, key_tmp.path()); + assert_eq!(chain.len(), 2); + let ids: Vec<&str> = chain.iter().map(|t| t.entity_id.as_str()).collect(); + assert!(ids.contains(&"org-a")); + assert!(ids.contains(&"org-b")); +} + +#[tokio::test] +async fn login_refuses_zero_memberships_when_tenancy_enabled() { + let (store, tenant_config) = + seeded_auth_store_with_memberships(0, &["member"]).await; + let (app, _key, _store) = login_app_with(store, Some(tenant_config)).await; + let (status, body) = post_login(app, r#"{"username":"alice","password":"dev"}"#).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "body: {body}"); + assert_eq!(body["error"], "no tenant assigned"); + assert_eq!(body["code"], "UNAUTHORIZED"); + assert_eq!(body["status"], 401); +} + +#[tokio::test] +async fn login_allows_platform_admin_with_zero_memberships() { + let (store, tenant_config) = + seeded_auth_store_with_memberships(0, &["platform_admin"]).await; + let (app, key_tmp, _store) = login_app_with(store, Some(tenant_config)).await; + let (status, body) = post_login(app, r#"{"username":"alice","password":"dev"}"#).await; + assert_eq!(status, StatusCode::OK, "body: {body}"); + let token = body["token"].as_str().expect("token field is a string"); + // No memberships => no tenant_chain claim on the minted token. + let chain = decode_tenant_chain(token, key_tmp.path()); + assert!(chain.is_empty()); +} + /// Failed credential validation must not stamp `last_login` — that field /// records *successful* logins only. #[tokio::test] diff --git a/crates/schema-forge-backend/src/entity_auth_store.rs b/crates/schema-forge-backend/src/entity_auth_store.rs index 8ded596..18b1a6d 100644 --- a/crates/schema-forge-backend/src/entity_auth_store.rs +++ b/crates/schema-forge-backend/src/entity_auth_store.rs @@ -32,6 +32,7 @@ use schema_forge_core::types::{ use crate::entity::Entity; use crate::error::BackendError; +use crate::tenant::TenantRef; use crate::traits::EntityStore; use crate::user_store::{AuthStore, ForgeUser}; @@ -43,6 +44,10 @@ const DISPLAY_NAME_FIELD: &str = "display_name"; const ACTIVE_FIELD: &str = "active"; const LAST_LOGIN_FIELD: &str = "last_login"; +const TM_USER_FIELD: &str = "user"; +const TM_TENANT_TYPE_FIELD: &str = "tenant_type"; +const TM_TENANT_ID_FIELD: &str = "tenant_id"; + /// Function that maps a role name to a numeric rank, returning `None` /// for unregistered roles. /// @@ -87,6 +92,13 @@ pub struct EntityAuthStore { store: Arc, user_schema: SchemaDefinition, role_rank_resolver: RoleRankResolver, + /// `TenantMembership` schema definition, used to query the user's + /// flat membership set during login/refresh. `None` when the + /// deployment has not seeded the system schema yet (e.g. a + /// `mem://` smoke test that skips system-schema seeding) — in that + /// case [`AuthStore::list_tenant_memberships`] returns an empty + /// `Vec`, mirroring the "no memberships configured" path. + tenant_membership_schema: Option, } /// Object-safe variant of [`EntityStore`] for the `Arc` storage @@ -219,9 +231,26 @@ impl EntityAuthStore { store, user_schema, role_rank_resolver, + tenant_membership_schema: None, } } + /// Attach the `TenantMembership` schema so [`AuthStore::list_tenant_memberships`] + /// can query the user's membership rows. + /// + /// Optional builder method: deployments without the system schema + /// seeded (or running tenancy-disabled smoke tests) can skip it, + /// and `list_tenant_memberships` will return an empty `Vec`. + pub fn with_tenant_membership_schema(mut self, schema: SchemaDefinition) -> Self { + debug_assert_eq!( + schema.name.as_str(), + "TenantMembership", + "with_tenant_membership_schema must be called with the TenantMembership SchemaDefinition" + ); + self.tenant_membership_schema = Some(schema); + self + } + /// Returns the password_hash field type from the User schema, used by /// migrations to verify the schema declares the column the auth /// store needs. @@ -382,6 +411,22 @@ fn extract_integer(entity: &Entity, field: &str) -> Option { } } +/// Build a [`TenantRef`] from a `TenantMembership` entity row. +/// +/// Pure: no I/O, no state. Returns `None` if either required field is +/// missing or carries the wrong `DynamicValue` variant — callers should +/// skip the row rather than fail the whole login, because a single +/// malformed row should not lock the user out of an otherwise valid +/// membership set. +pub(crate) fn entity_to_tenant_ref(entity: &Entity) -> Option { + let schema = extract_text(entity, TM_TENANT_TYPE_FIELD)?; + let entity_id = extract_text(entity, TM_TENANT_ID_FIELD)?; + if schema.is_empty() || entity_id.is_empty() { + return None; + } + Some(TenantRef { schema, entity_id }) +} + // --------------------------------------------------------------------------- // AuthStore implementation // --------------------------------------------------------------------------- @@ -532,6 +577,37 @@ impl AuthStore for EntityAuthStore { Ok(()) } + async fn list_tenant_memberships( + &self, + username: &str, + ) -> Result, BackendError> { + // Tenancy not configured for this deployment — return empty. + // The login handler treats "0 memberships + tenancy enabled" as + // a 401; with no schema attached we don't know whether tenancy + // is enabled, so we leave that decision to the caller. + let Some(tm_schema) = self.tenant_membership_schema.as_ref() else { + return Ok(Vec::new()); + }; + + // Resolve the user's EntityId first; without the row there are + // no memberships to read regardless of the TenantMembership + // table's contents. + let Some(user_entity) = self.find_entity_by_username(username).await? else { + return Ok(Vec::new()); + }; + + let query = Query::new(tm_schema.id.clone()).with_filter(Filter::eq( + FieldPath::single(TM_USER_FIELD), + DynamicValue::Ref(user_entity.id.clone()), + )); + let result = self.store.query(&query).await?; + Ok(result + .entities + .iter() + .filter_map(entity_to_tenant_ref) + .collect()) + } + async fn record_login( &self, username: &str, @@ -924,6 +1000,235 @@ mod tests { assert_eq!(store.count_users().await.unwrap(), 1); } + fn tenant_membership_schema() -> SchemaDefinition { + SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("TenantMembership").unwrap(), + vec![ + FieldDefinition::with_annotations( + FieldName::new(TM_USER_FIELD).unwrap(), + FieldType::Relation { + target: SchemaName::new("User").unwrap(), + cardinality: schema_forge_core::types::Cardinality::One, + }, + vec![FieldModifier::Required], + vec![], + ), + FieldDefinition::with_annotations( + FieldName::new(TM_TENANT_TYPE_FIELD).unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + ), + FieldDefinition::with_annotations( + FieldName::new(TM_TENANT_ID_FIELD).unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![FieldModifier::Required], + vec![], + ), + FieldDefinition::new( + FieldName::new("role").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + ), + ], + Vec::new(), + ) + .unwrap() + } + + /// Build an `EntityAuthStore` whose underlying `MemStore` is also + /// returned for direct row seeding. The auth store carries the + /// `TenantMembership` schema so `list_tenant_memberships` can query + /// the seeded rows. + fn store_with_tm() -> (EntityAuthStore, Arc, SchemaDefinition) { + let mem = Arc::new(MemStore::new()); + let mem_dyn: Arc = mem.clone(); + let tm_schema = tenant_membership_schema(); + let auth = EntityAuthStore::new( + mem_dyn, + user_schema(), + Arc::new(|_role: &str| Some(0)), + ) + .with_tenant_membership_schema(tm_schema.clone()); + (auth, mem, tm_schema) + } + + fn make_tm_row( + tm_schema: &SchemaDefinition, + user_id: &EntityId, + tenant_type: &str, + tenant_id: &str, + ) -> Entity { + let mut fields: BTreeMap = BTreeMap::new(); + fields.insert( + TM_USER_FIELD.to_string(), + DynamicValue::Ref(user_id.clone()), + ); + fields.insert( + TM_TENANT_TYPE_FIELD.to_string(), + DynamicValue::Text(tenant_type.to_string()), + ); + fields.insert( + TM_TENANT_ID_FIELD.to_string(), + DynamicValue::Text(tenant_id.to_string()), + ); + Entity::new(tm_schema.name.clone(), fields) + } + + #[tokio::test] + async fn list_tenant_memberships_returns_empty_for_unknown_user() { + let (auth, _mem, _tm) = store_with_tm(); + let memberships = auth.list_tenant_memberships("ghost").await.unwrap(); + assert!(memberships.is_empty()); + } + + #[tokio::test] + async fn list_tenant_memberships_returns_empty_when_schema_not_attached() { + // Without `with_tenant_membership_schema`, list_tenant_memberships + // is inert — matches the "tenancy not configured" deployment path. + let store = store_with_ranks(&[]); + store + .create_user("alice", "secret123", &[], "Alice") + .await + .unwrap(); + let memberships = store.list_tenant_memberships("alice").await.unwrap(); + assert!(memberships.is_empty()); + } + + #[tokio::test] + async fn list_tenant_memberships_returns_single_row() { + let (auth, mem, tm_schema) = store_with_tm(); + auth.create_user("alice", "secret123", &[], "Alice") + .await + .unwrap(); + let alice = auth + .find_entity_by_username("alice") + .await + .unwrap() + .unwrap(); + EntityStore::create( + mem.as_ref(), + &make_tm_row(&tm_schema, &alice.id, "Organization", "org-a"), + ) + .await + .unwrap(); + + let memberships = auth.list_tenant_memberships("alice").await.unwrap(); + assert_eq!(memberships.len(), 1); + assert_eq!(memberships[0].schema, "Organization"); + assert_eq!(memberships[0].entity_id, "org-a"); + } + + #[tokio::test] + async fn list_tenant_memberships_returns_multiple_rows() { + let (auth, mem, tm_schema) = store_with_tm(); + auth.create_user("alice", "secret123", &[], "Alice") + .await + .unwrap(); + let alice = auth + .find_entity_by_username("alice") + .await + .unwrap() + .unwrap(); + for (kind, id) in &[("Organization", "org-a"), ("Organization", "org-b")] { + EntityStore::create( + mem.as_ref(), + &make_tm_row(&tm_schema, &alice.id, kind, id), + ) + .await + .unwrap(); + } + + let memberships = auth.list_tenant_memberships("alice").await.unwrap(); + assert_eq!(memberships.len(), 2); + let ids: Vec<&str> = memberships.iter().map(|m| m.entity_id.as_str()).collect(); + assert!(ids.contains(&"org-a")); + assert!(ids.contains(&"org-b")); + } + + #[tokio::test] + async fn list_tenant_memberships_filters_by_user_ref() { + let (auth, mem, tm_schema) = store_with_tm(); + auth.create_user("alice", "secret123", &[], "Alice") + .await + .unwrap(); + auth.create_user("bob", "secret123", &[], "Bob") + .await + .unwrap(); + let alice = auth + .find_entity_by_username("alice") + .await + .unwrap() + .unwrap(); + let bob = auth + .find_entity_by_username("bob") + .await + .unwrap() + .unwrap(); + + EntityStore::create( + mem.as_ref(), + &make_tm_row(&tm_schema, &alice.id, "Organization", "org-a"), + ) + .await + .unwrap(); + EntityStore::create( + mem.as_ref(), + &make_tm_row(&tm_schema, &bob.id, "Organization", "org-b"), + ) + .await + .unwrap(); + + let alice_memberships = auth.list_tenant_memberships("alice").await.unwrap(); + assert_eq!(alice_memberships.len(), 1); + assert_eq!(alice_memberships[0].entity_id, "org-a"); + + let bob_memberships = auth.list_tenant_memberships("bob").await.unwrap(); + assert_eq!(bob_memberships.len(), 1); + assert_eq!(bob_memberships[0].entity_id, "org-b"); + } + + #[test] + fn entity_to_tenant_ref_returns_some_for_valid_row() { + let mut fields: BTreeMap = BTreeMap::new(); + fields.insert( + TM_TENANT_TYPE_FIELD.to_string(), + DynamicValue::Text("Organization".to_string()), + ); + fields.insert( + TM_TENANT_ID_FIELD.to_string(), + DynamicValue::Text("org-a".to_string()), + ); + let entity = Entity::new(SchemaName::new("TenantMembership").unwrap(), fields); + let tr = entity_to_tenant_ref(&entity).unwrap(); + assert_eq!(tr.schema, "Organization"); + assert_eq!(tr.entity_id, "org-a"); + } + + #[test] + fn entity_to_tenant_ref_returns_none_for_missing_fields() { + let entity = Entity::new( + SchemaName::new("TenantMembership").unwrap(), + BTreeMap::new(), + ); + assert!(entity_to_tenant_ref(&entity).is_none()); + } + + #[test] + fn entity_to_tenant_ref_returns_none_for_empty_strings() { + let mut fields: BTreeMap = BTreeMap::new(); + fields.insert( + TM_TENANT_TYPE_FIELD.to_string(), + DynamicValue::Text(String::new()), + ); + fields.insert( + TM_TENANT_ID_FIELD.to_string(), + DynamicValue::Text("org-a".to_string()), + ); + let entity = Entity::new(SchemaName::new("TenantMembership").unwrap(), fields); + assert!(entity_to_tenant_ref(&entity).is_none()); + } + #[test] fn compute_role_rank_picks_max() { let ranks: BTreeMap<&str, i64> = diff --git a/crates/schema-forge-backend/src/user_store.rs b/crates/schema-forge-backend/src/user_store.rs index 8e8b4fe..62ff1d5 100644 --- a/crates/schema-forge-backend/src/user_store.rs +++ b/crates/schema-forge-backend/src/user_store.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; use crate::entity::Entity; use crate::error::BackendError; +use crate::tenant::TenantRef; /// A user record (without password_hash). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -97,6 +98,22 @@ pub trait AuthStore: Send + Sync { new_password: &str, ) -> impl Future> + Send; + /// List the tenant memberships granted to `username`. + /// + /// Returns the flat set of `(tenant_type, tenant_id)` tuples backing + /// the PASETO `tenant_chain` custom claim. Each entry is a tenant the + /// user belongs to — the set carries no inherent order and no implied + /// hierarchy walk. The per-request active tenant and its ancestor + /// chain are resolved upstream by the `tenant_scope` middleware. + /// + /// Returns an empty `Vec` for unknown users or users with no + /// memberships; policy decisions ("0 memberships + tenancy enabled + /// → refuse login") belong to the caller, not the store. + fn list_tenant_memberships( + &self, + username: &str, + ) -> impl Future, BackendError>> + Send; + /// Stamp the user's `last_login` field to `at`. /// /// Called from the login handler after credentials validate, before the diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 98fd9c2..90eaf33 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -329,11 +329,19 @@ pub async fn run( // runtime posture to unauthenticated callers (the login screen). let login_auth_store: Arc = auth_store.clone(); let meta_info = build_meta_info(&db_params); + let tenant_config_layer: Arc> = + Arc::new(init_data.tenant_config.clone()); + let tenant_scope_state = schema_forge_acton::middleware::tenant_scope::TenantScopeState { + entity_store: entity_store.clone(), + tenant_config: tenant_config_layer.clone(), + }; let routes = build_versioned_routes( login_auth_store, paseto_generator, meta_info, resolved_principal_claims, + tenant_config_layer, + tenant_scope_state, ); // LOCAL WORKAROUND for issue #55: `acton-service`'s default `/health` @@ -581,11 +589,17 @@ fn build_entity_auth_store( let resolver: schema_forge_backend::entity_auth_store::RoleRankResolver = Arc::new(move |role: &str| policy_store.current().role_ranks.get(role)); - Ok(Arc::new(schema_forge_backend::EntityAuthStore::new( - entity_store, - user_schema, - resolver, - ))) + let mut store = + schema_forge_backend::EntityAuthStore::new(entity_store, user_schema, resolver); + // Attach the TenantMembership schema when the system seed registered + // it (which is always for non-legacy deployments). Without it, + // `list_tenant_memberships` returns an empty `Vec` and the login + // handler treats the user as unscoped — which is fine for the + // tenancy-disabled path. + if let Some(tm_schema) = init_data.registry.get("TenantMembership").cloned() { + store = store.with_tenant_membership_schema(tm_schema); + } + Ok(Arc::new(store)) } /// Build a [`PasetoGenerator`] from the loaded acton-service config. @@ -663,6 +677,8 @@ fn build_versioned_routes( paseto_generator: Arc, meta_info: Arc, principal_claims: Arc, + tenant_config: Arc>, + tenant_scope_state: schema_forge_acton::middleware::tenant_scope::TenantScopeState, ) -> acton_service::service_builder::VersionedRoutes { // Cloned into the add_version closure so the login handler can // extract them via axum::Extension. @@ -670,15 +686,27 @@ fn build_versioned_routes( let generator_layer = paseto_generator; let meta_layer = meta_info; let principal_claims_layer = principal_claims; + let tenant_config_layer = tenant_config; VersionedApiBuilder::::with_config() .with_base_path("/api") .add_version(ApiVersion::V1, move |router| { use axum::Extension; SchemaForgeExtension::versioned_forge_routes(router) + // The tenant_scope middleware runs AFTER acton-service's + // token middleware (which injects Claims) and BEFORE the + // handlers below. Layer order in axum is reverse: the last + // `.layer()` runs first on the request, so wire tenant_scope + // BEFORE the Extensions block to ensure handlers see the + // mutated Claims. + .layer(axum::middleware::from_fn_with_state( + tenant_scope_state.clone(), + schema_forge_acton::middleware::tenant_scope::middleware, + )) .layer(Extension(auth_store_layer)) .layer(Extension(generator_layer)) .layer(Extension(meta_layer)) .layer(Extension(principal_claims_layer)) + .layer(Extension(tenant_config_layer)) }) .build_routes() } @@ -906,7 +934,7 @@ mod tests { let resolver: schema_forge_backend::entity_auth_store::RoleRankResolver = Arc::new(|_role: &str| None); let auth_store: Arc = Arc::new( - EntityAuthStore::new(entity_store, user_schema, resolver), + EntityAuthStore::new(entity_store.clone(), user_schema, resolver), ); let meta = Arc::new(schema_forge_acton::MetaInfo::new( @@ -915,6 +943,19 @@ mod tests { 3600, )); let principal_claims = Arc::new(schema_forge_acton::authz::PrincipalClaimMappings::default()); - let _routes = build_versioned_routes(auth_store, generator, meta, principal_claims); + let tenant_config = Arc::new(None::); + let tenant_scope_state = + schema_forge_acton::middleware::tenant_scope::TenantScopeState { + entity_store: entity_store.clone(), + tenant_config: tenant_config.clone(), + }; + let _routes = build_versioned_routes( + auth_store, + generator, + meta, + principal_claims, + tenant_config, + tenant_scope_state, + ); } } diff --git a/docs/principal-claims-reference.md b/docs/principal-claims-reference.md index d226d9b..caea08a 100644 --- a/docs/principal-claims-reference.md +++ b/docs/principal-claims-reference.md @@ -26,6 +26,8 @@ malformed, and the restart-required hot-reload limitation. 6. [Hot-reload and restart requirements](#6-hot-reload-and-restart-requirements) 7. [Reserved names and identifier rules](#7-reserved-names-and-identifier-rules) 8. [Worked example: per-org file scoping](#8-worked-example-per-org-file-scoping) +9. [IN-side: projecting User columns into the token at login](#9-in-side-projecting-user-columns-into-the-token-at-login) +10. [Tenant chain and the `X-Active-Tenant` contract](#10-tenant-chain-and-the-x-active-tenant-contract) --- @@ -418,3 +420,142 @@ source = { user_field = "client_org_id" } - Operator reassigns alice from `org-42` to `org-99`: alice's existing token still carries `org-42` until expiry. On her next `/auth/refresh` (or fresh login) the new token carries `org-99`. + +--- + +## 10. Tenant chain and the `X-Active-Tenant` contract + +`tenant_chain` is the PASETO custom claim that carries the user's tenant +scope. It is consumed by two pieces of the runtime: + +- the Cedar adapter (`crates/schema-forge-acton/src/authz/adapters.rs`) + projects every chain entry into `principal.parents` so policies can + express `resource._tenant in principal` for hierarchical scoping, and +- the query layer (`crates/schema-forge-acton/src/access.rs`) filters + reads/writes by `_tenant IN ` so a list endpoint returns rows + belonging to any tenant in the chain. + +Two distinct concepts share the claim name. Understand both: + +### 10.1 Token shape: flat memberships + +The token's `tenant_chain` is **the flat set of `TenantMembership` rows +for the user**. It is NOT a parent → child hierarchy walk. A user +belonging to three tenants ships a three-entry chain in their token, +order unspecified. The token captures *available* scope, not *active* +scope. + +`/auth/login` reads `TenantMembership` where `user = ` and writes the result into `custom.tenant_chain`. `/auth/refresh` +re-reads on every call — same contract as §9.3. A grant or revocation +since the last login takes effect on the next refresh. + +### 10.2 Request shape: effective scope via `X-Active-Tenant` + +Per request, clients select which membership scopes this request with: + +``` +X-Active-Tenant: : +``` + +For example: `X-Active-Tenant: Organization:org_01k...`. + +The `tenant_scope` middleware (between the token middleware and the +forge handlers): + +1. Validates the header is in the token's memberships. Header not + present in chain → 403 `ACTIVE_TENANT_FORBIDDEN`. +2. Walks the `@tenant(parent:)` hierarchy from that leaf up to the root, + fetching each level's entity to read its parent reference. The + resulting walk is the *effective scope* for this request. +3. Rewrites `tenant_chain` on the request's `Claims` to that effective + walk before downstream handlers see it. + +Header rules: + +- **Header absent + exactly one membership**: middleware uses the sole + membership as the active tenant. No 400. +- **Header absent + multiple memberships**: 400 `ACTIVE_TENANT_REQUIRED`. + The client must pick. +- **Header malformed** (not `:`): 400 + `ACTIVE_TENANT_INVALID`. +- **Header references a tenant the user is not a member of**: 403 + `ACTIVE_TENANT_FORBIDDEN`. Closes impersonation. + +### 10.3 Zero-membership policy + +If `@tenant` annotations are present in the deployment's schemas +(tenancy enabled) and a user has zero `TenantMembership` rows, `/auth/login` +responds **401 `no tenant assigned`** — except for `platform_admin`, +which bypasses tenancy entirely (matches the +`access.rs::inject_tenant_scope` bypass and the Cedar adapter parent +projection). + +This is fail-closed by design: a user with no memberships under enabled +tenancy has no defensible scope to project. Operators must explicitly +grant access by writing a `TenantMembership` row. + +### 10.4 Hierarchy walk: where the parent fields come from + +Schemas declare their tenant level with `@tenant`: + +``` +@tenant(root) +schema Organization { ... } + +@tenant(parent: "Organization") +schema Department { + organization: -> Organization required + ... +} +``` + +`TenantConfig` reads these and stores, per level, the `parent_field` +that holds the parent reference (`Department.organization` above). When +the middleware walks from `Department:dept-1` upward, it fetches +`Department/dept-1`, reads `organization`, and continues from +`Organization/`. Stops when the level has no parent (root reached) +or the entity is missing (logged; chain collapses to leaf only so the +request stays servable but unlocks no ancestors). + +### 10.5 Out-of-band tokens + +`schemaforge token generate --tenant-chain '...'` still works for CI / +operations. The JSON value passed must be a `Vec` — same +shape as `tenant_chain.list()` from a `/auth/login` response. The CLI +contract is unchanged. + +### 10.6 Worked example + +Tenant model: `Organization` (root) and a flat membership table. +`alice` belongs to `org-a` only. `bob` belongs to `org-a` AND `org-b`. + +```sh +# alice: sole membership → no header required. +curl -sf -X POST http://localhost:3000/api/v1/forge/auth/login \ + -d '{"username":"alice","password":"..."}' +# token's custom.tenant_chain = [{schema:"Organization", entity_id:"org-a"}] + +curl -sf -H "authorization: Bearer $ALICE_TOKEN" \ + http://localhost:3000/api/v1/forge/schemas/Opportunity/entities +# returns: rows scoped to org-a only. + +# bob: multi-membership → header required. +curl -sf -X POST http://localhost:3000/api/v1/forge/auth/login \ + -d '{"username":"bob","password":"..."}' +# token's custom.tenant_chain = [{...org-a}, {...org-b}] + +curl -sf -H "authorization: Bearer $BOB_TOKEN" \ + http://localhost:3000/api/v1/forge/schemas/Opportunity/entities +# 400 ACTIVE_TENANT_REQUIRED + +curl -sf -H "authorization: Bearer $BOB_TOKEN" \ + -H "X-Active-Tenant: Organization:org-a" \ + http://localhost:3000/api/v1/forge/schemas/Opportunity/entities +# returns: rows scoped to org-a. + +curl -sf -H "authorization: Bearer $BOB_TOKEN" \ + -H "X-Active-Tenant: Organization:org-c" \ + http://localhost:3000/api/v1/forge/schemas/Opportunity/entities +# 403 ACTIVE_TENANT_FORBIDDEN — bob isn't a member of org-c. +```