From 5ebc328e2f29331875cf22840643545ac4fcfdcb Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Sun, 11 Oct 2026 13:45:07 -0600 Subject: [PATCH] fix(authz): prove query fields within enforced tenant scopes --- CHANGELOG.md | 11 + Cargo.lock | 4 +- crates/schema-forge-acton/Cargo.toml | 6 +- crates/schema-forge-acton/src/access.rs | 128 ++-- .../schema-forge-acton/src/authz/adapters.rs | 21 + crates/schema-forge-acton/src/authz/engine.rs | 51 +- .../src/authz/read_scope.rs | 376 ++++++++++- .../src/graphql/resolvers.rs | 18 +- .../schema-forge-acton/src/routes/entities.rs | 74 ++- .../schema-forge-acton/src/routes/export.rs | 32 +- .../tests/tenant_query_field_authorization.rs | 601 ++++++++++++++++++ crates/schema-forge-cli/Cargo.toml | 4 +- docs/custom-policy-context.md | 6 +- docs/plans/tenant-query-proof-256.md | 20 + 14 files changed, 1236 insertions(+), 116 deletions(-) create mode 100644 crates/schema-forge-acton/tests/tenant_query_field_authorization.rs create mode 100644 docs/plans/tenant-query-proof-256.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8209c44..013f325 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ is pre-1.0; breaking changes bump the **minor** version per ## [Unreleased] +## [0.52.3] - 2026-10-11 + +### Fixed + +- Allow filters, sorts, and exports on readable `@field_access` fields when + tenant-dependent custom policies can be proved safe for every tenant in the + enforced query scope. Account for principal-only Boolean conditions that + safely exclude a policy for the caller, including platform administrators. + Preserve denials for forbidden tenants, record-dependent field policies, + and evaluation errors. + ## [0.52.2] - 2026-10-10 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a8ea745..4f975bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6743,7 +6743,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.50.1" +version = "0.50.2" dependencies = [ "acton-service", "arc-swap", @@ -6829,7 +6829,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.52.2" +version = "0.52.3" dependencies = [ "acton-service", "assert_cmd", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index b1f134d..9592e6e 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.50.1" +version = "0.50.2" edition = "2021" [dependencies] @@ -183,3 +183,7 @@ required-features = ["postgres"] [[test]] name = "entity_get_projection" required-features = ["test-surrealdb"] + +[[test]] +name = "tenant_query_field_authorization" +required-features = ["test-surrealdb"] diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 3c9814b..efe61f5 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -353,53 +353,89 @@ pub fn inject_tenant_scope( tenant_config: &Option, schema: &SchemaDefinition, ) { - if !schema.is_tenanted() { - return; + QueryTenantScope::from_request(claims, tenant_config, schema).inject(query); +} + +/// Trusted query confinement, captured once for both field proof and storage. +/// Private fields prevent caller predicates from manufacturing a tenant proof. +#[derive(Debug, Clone, Default)] +pub(crate) struct QueryTenantScope { + tenant_filter: Option, + tenant_ids: Vec, +} + +impl QueryTenantScope { + pub(crate) fn from_request( + claims: Option<&Claims>, + tenant_config: &Option, + schema: &SchemaDefinition, + ) -> Self { + if !schema.is_tenanted() { + return Self::default(); + } + let _config = match tenant_config { + Some(c) if c.is_enabled() => c, + _ => return Self::default(), + }; + let claims = match claims { + Some(c) => c, + None => return Self::default(), + }; + if claims.has_role(PLATFORM_ADMIN_ROLE) { + return Self::default(); + } + let tenant_chain: Vec = claims + .custom_claim_as::>("tenant_chain") + .unwrap_or_default(); + if tenant_chain.is_empty() { + return Self::default(); + } + // 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_field = if is_tenant_root(schema) { + "id" + } else { + "_tenant" + }; + let tenant_filter = if tenant_values.len() == 1 { + Filter::eq( + FieldPath::single(tenant_field), + tenant_values.into_iter().next().unwrap(), + ) + } else { + Filter::in_set(FieldPath::single(tenant_field), tenant_values) + }; + Self { + tenant_filter: Some(tenant_filter), + tenant_ids: tenant_chain + .into_iter() + .map(|tenant| tenant.entity_id) + .collect(), + } } - let _config = match tenant_config { - Some(c) if c.is_enabled() => c, - _ => return, - }; - let claims = match claims { - Some(c) => c, - None => return, - }; - if claims.has_role(PLATFORM_ADMIN_ROLE) { - return; + + pub(crate) fn bound_tenants(&self) -> Option<&[String]> { + self.tenant_filter + .as_ref() + .map(|_| self.tenant_ids.as_slice()) } - let tenant_chain: Vec = claims - .custom_claim_as::>("tenant_chain") - .unwrap_or_default(); - if tenant_chain.is_empty() { - return; + + pub(crate) fn inject(&self, query: &mut Query) { + if let Some(filter) = &self.tenant_filter { + query.filter = Some(match query.filter.take() { + Some(existing) => Filter::and(vec![existing, filter.clone()]), + None => filter.clone(), + }); + } } - // 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_field = if is_tenant_root(schema) { - "id" - } else { - "_tenant" - }; - let tenant_filter = if tenant_values.len() == 1 { - Filter::eq( - FieldPath::single(tenant_field), - tenant_values.into_iter().next().unwrap(), - ) - } else { - Filter::in_set(FieldPath::single(tenant_field), 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. @@ -1255,7 +1291,9 @@ mod tests { let claims = make_claims_with_tenant(&["member"], "organization_a"); let config = make_enabled_tenant_config(); let mut query = Query::new(schema.id.clone()); - inject_tenant_scope(&mut query, Some(&claims), &config, &schema); + let scope = QueryTenantScope::from_request(Some(&claims), &config, &schema); + assert!(scope.bound_tenants().is_none()); + scope.inject(&mut query); assert!(query.filter.is_none()); let mut fields = BTreeMap::new(); inject_tenant_on_create(&mut fields, Some(&claims), &config, &schema); diff --git a/crates/schema-forge-acton/src/authz/adapters.rs b/crates/schema-forge-acton/src/authz/adapters.rs index 133f645..49e70f9 100644 --- a/crates/schema-forge-acton/src/authz/adapters.rs +++ b/crates/schema-forge-acton/src/authz/adapters.rs @@ -302,6 +302,15 @@ pub fn build_resource_entity( /// infer them from the default values. Concrete resources use false, even when /// their stored values happen to equal these defaults. pub fn build_resource_placeholder(schema: &SchemaDefinition) -> Result { + build_query_resource_placeholder(schema, None) +} + +/// A typed query witness; tenant presence is certified by storage confinement. +/// Parents remain empty, matching the concrete resource adapter. +pub(crate) fn build_query_resource_placeholder( + schema: &SchemaDefinition, + tenant: Option<&str>, +) -> Result { let raw = format!("{}::\"_any\"", schema.name.as_str()); let uid = EntityUid::from_str(&raw).map_err(|e| AdapterError::InvalidIdentifier { value: raw, @@ -323,6 +332,18 @@ pub fn build_resource_placeholder(schema: &SchemaDefinition) -> Result, schema: &SchemaDefinition, field_name: &str, + scope: &crate::access::QueryTenantScope, ) -> Result { let snapshot = store.current(); - if !super::read_scope::field_read_is_record_independent(&snapshot, schema, field_name, claims) { - return Err(AuthzError::Request( - "field readability requires concrete record authorization".into(), - )); + let candidates: Vec<_> = match scope.bound_tenants() { + Some(tenants) => tenants.iter().map(|tenant| Some(tenant.as_str())).collect(), + None => vec![None], + }; + let mut allowed = None; + for tenant in candidates { + if !super::read_scope::field_read_is_record_independent( + &snapshot, schema, field_name, claims, tenant, + ) { + return Err(AuthzError::Request( + "field readability requires concrete record authorization".into(), + )); + } + let decision = authorize_field_resource( + Arc::clone(&snapshot), + claims, + schema, + FieldResource::QueryWitness(tenant), + field_name, + FieldDirection::Read, + )?; + if !decision.is_allow() { + return Ok(decision); + } + allowed = Some(decision); } - authorize_field_resource( - snapshot, - claims, - schema, - FieldResource::Placeholder, - field_name, - FieldDirection::Read, - ) + allowed.ok_or_else(|| AuthzError::Request("empty query tenant domain".into())) } enum FieldResource<'a> { Complete(&'a Entity), Input(&'a Entity), + #[cfg(any(feature = "sse", test))] Placeholder, + QueryWitness(Option<&'a str>), } fn authorize_field_resource( @@ -399,7 +416,13 @@ fn authorize_field_resource( let (resource_entity, complete, resource_is_placeholder) = match resource { FieldResource::Complete(entity) => (build_resource_entity(schema, entity)?, true, false), FieldResource::Input(entity) => (build_resource_entity(schema, entity)?, false, false), + #[cfg(any(feature = "sse", test))] FieldResource::Placeholder => (build_resource_placeholder(schema)?, true, true), + FieldResource::QueryWitness(tenant) => ( + build_query_resource_placeholder(schema, tenant)?, + true, + true, + ), }; let resource_uid = resource_entity.uid().clone(); let mut all_entities = principal_entities; diff --git a/crates/schema-forge-acton/src/authz/read_scope.rs b/crates/schema-forge-acton/src/authz/read_scope.rs index dfaba92..eb7f937 100644 --- a/crates/schema-forge-acton/src/authz/read_scope.rs +++ b/crates/schema-forge-acton/src/authz/read_scope.rs @@ -9,15 +9,16 @@ use std::collections::{HashMap, HashSet}; use acton_service::middleware::Claims; use cedar_policy::{ - ActionConstraint, Entities, EntityUid, Policy, PolicySet, PrincipalConstraint, - ResourceConstraint, RestrictedExpression, + ActionConstraint, Authorizer, Context, Decision, Entities, EntityUid, Policy, PolicySet, + PrincipalConstraint, Request, ResourceConstraint, RestrictedExpression, }; use schema_forge_backend::{auth::CedarReadScope, TenantRef}; use schema_forge_core::types::{Cardinality, FieldType, SchemaDefinition}; use super::{ adapters::{ - action_entity_uid, build_principal_entities, build_resource_placeholder, principal_uid, + action_entity_uid, build_principal_entities, build_query_resource_placeholder, + build_resource_placeholder, principal_uid, }, namespace::ActionVerb, PolicyStoreSnapshot, @@ -68,9 +69,11 @@ pub(crate) fn field_read_is_record_independent( schema: &SchemaDefinition, field_name: &str, claims: Option<&Claims>, + tenant: Option<&str>, ) -> bool { - compatible_resource_shape(snapshot, schema) - && field_policy_independence_proof(snapshot, schema, field_name, claims).is_some() + tenant.is_none_or(canonical_identity) + && compatible_resource_shape(snapshot, schema) + && field_policy_independence_proof(snapshot, schema, field_name, claims, tenant).is_some() } fn field_policy_independence_proof( @@ -78,6 +81,7 @@ fn field_policy_independence_proof( schema: &SchemaDefinition, field_name: &str, claims: Option<&Claims>, + tenant: Option<&str>, ) -> Option<()> { let action = super::namespace::field_read_action_uid(schema.name.as_str(), field_name) .parse::() @@ -99,7 +103,28 @@ fn field_policy_independence_proof( Vec::new(), ), }; + let mut entities = entities; + let resource = build_query_resource_placeholder(schema, tenant).ok()?; + let resource_uid = resource.uid(); + entities.push(resource); let entities = Entities::from_entities(entities, Some(&snapshot.schema)).ok()?; + let request = Request::new( + principal.clone(), + action.clone(), + resource_uid, + Context::from_pairs([( + "resource_is_placeholder".into(), + RestrictedExpression::new_bool(true), + )]) + .ok()?, + Some(&snapshot.schema), + ) + .ok()?; + let condition_context = ConditionContext { + request, + entities, + tenant_bound: tenant.is_some(), + }; let generated = generated_policies(schema)?; let mut expected = applicable_asts(&generated, &action, schema)?; for policy in snapshot @@ -112,8 +137,11 @@ fn field_policy_independence_proof( expected.swap_remove(index); continue; } - if principal_constraint_matches(&policy.principal_constraint(), &principal, &entities) - && !custom_field_policy_is_record_independent(policy, &ast) + if principal_constraint_matches( + &policy.principal_constraint(), + &principal, + &condition_context.entities, + ) && !custom_field_policy_is_constant(policy, &ast, &condition_context) { return None; } @@ -140,9 +168,11 @@ fn principal_constraint_matches( } } -fn custom_field_policy_is_record_independent(policy: &Policy, ast: &serde_json::Value) -> bool { - // A record identity or membership head still depends on which record a - // predicate would select, even if its conditions only inspect the caller. +fn custom_field_policy_is_constant( + policy: &Policy, + ast: &serde_json::Value, + context: &ConditionContext, +) -> bool { matches!( policy.resource_constraint(), ResourceConstraint::Any | ResourceConstraint::Is(_) @@ -154,13 +184,159 @@ fn custom_field_policy_is_record_independent(policy: &Policy, ast: &serde_json:: matches!( condition.get("kind").and_then(serde_json::Value::as_str), Some("when" | "unless") - ) && condition - .get("body") - .is_some_and(record_independent_expression) + ) && condition.get("body").is_some_and(|body| { + matches!( + prove_condition(body, context, context.tenant_bound), + Some(ConditionTruth::Known(_)) + ) + }) }) }) } +struct ConditionContext { + request: Request, + entities: Entities, + tenant_bound: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ConditionTruth { + Known(bool), + /// A total Boolean, whose value may vary between unscoped tenants. + Unknown, +} + +/// Evaluate only expressions certified independent, or tenant expressions whose +/// presence is bound by enforcement. A single diagnostic error invalidates proof. +fn evaluate_boolean(expression: &serde_json::Value, context: &ConditionContext) -> Option { + let policy = Policy::from_json( + None, + serde_json::json!({ + "effect": "permit", "principal": {"op": "All"}, "action": {"op": "All"}, + "resource": {"op": "All"}, "conditions": [{"kind": "when", "body": expression}] + }), + ) + .ok()?; + let mut policies = PolicySet::new(); + policies.add(policy).ok()?; + let response = Authorizer::new().is_authorized(&context.request, &policies, &context.entities); + if response.diagnostics().errors().next().is_some() { + return None; + } + Some(response.decision() == Decision::Allow) +} + +fn tenant_attribute(expression: &serde_json::Value, operator: &str) -> bool { + expression.get(operator).is_some_and(|operands| { + operands.get("left") == Some(&serde_json::json!({"Var": "resource"})) + && operands.get("attr").and_then(serde_json::Value::as_str) == Some("_tenant") + }) +} + +fn guarantees_tenant_presence(expression: &serde_json::Value) -> bool { + tenant_attribute(expression, "has") + || expression.get("&&").is_some_and(|operands| { + ["left", "right"] + .iter() + .any(|key| operands.get(key).is_some_and(guarantees_tenant_presence)) + }) +} + +/// Ordered, totality-aware Boolean reasoning. `error && false` is an error in +/// Cedar; only total left operands can be absorbed by a constant right operand. +fn prove_condition( + expression: &serde_json::Value, + context: &ConditionContext, + tenant_present: bool, +) -> Option { + if record_independent_expression(expression) { + return evaluate_boolean(expression, context).map(ConditionTruth::Known); + } + if tenant_attribute(expression, "has") { + return Some(if tenant_present { + ConditionTruth::Known(true) + } else { + ConditionTruth::Unknown + }); + } + let object = expression.as_object().filter(|object| object.len() == 1)?; + let (operator, operands) = object.iter().next()?; + let left = || operands.get("left"); + let right = || operands.get("right"); + match operator.as_str() { + "==" | "!=" => { + let (left, right) = (left()?, right()?); + let tenant_and_literal = |tenant, literal: &serde_json::Value| { + tenant_attribute(tenant, ".") + && literal + .get("Value") + .and_then(|value| value.get("__entity")) + .is_some() + }; + if !tenant_present + || !(tenant_and_literal(left, right) || tenant_and_literal(right, left)) + { + return None; + } + if context.tenant_bound { + evaluate_boolean(expression, context).map(ConditionTruth::Known) + } else { + Some(ConditionTruth::Unknown) + } + } + "&&" | "||" => { + let (left, right) = (left()?, right()?); + let left_truth = prove_condition(left, context, tenant_present)?; + let conjunction = operator == "&&"; + if left_truth == ConditionTruth::Known(!conjunction) { + return Some(left_truth); + } + let right_truth = prove_condition( + right, + context, + tenant_present || (conjunction && guarantees_tenant_presence(left)), + )?; + match (left_truth, right_truth) { + (ConditionTruth::Known(_), right) => Some(right), + (_, ConditionTruth::Known(value)) if value != conjunction => { + Some(ConditionTruth::Known(value)) + } + _ => Some(ConditionTruth::Unknown), + } + } + "!" => match prove_condition(operands.get("arg")?, context, tenant_present)? { + ConditionTruth::Known(value) => Some(ConditionTruth::Known(!value)), + ConditionTruth::Unknown => Some(ConditionTruth::Unknown), + }, + "if-then-else" => { + let condition = operands.get("if")?; + match prove_condition(condition, context, tenant_present)? { + ConditionTruth::Known(value) => prove_condition( + operands.get(if value { "then" } else { "else" })?, + context, + tenant_present || (value && guarantees_tenant_presence(condition)), + ), + ConditionTruth::Unknown => { + let then_truth = prove_condition( + operands.get("then")?, + context, + tenant_present || guarantees_tenant_presence(condition), + )?; + let else_truth = + prove_condition(operands.get("else")?, context, tenant_present)?; + Some(if then_truth == else_truth { + then_truth + } else { + ConditionTruth::Unknown + }) + } + } + } + _ => None, + } +} + /// A closed whitelist of Cedar EST forms. Never search policy text: strings, /// annotations, and attribute names are not variable references. Direct caller /// attributes are fixed by the trusted adapter; other dereferences could alias @@ -466,7 +642,8 @@ mod tests { &snapshot(¤t, ""), ¤t, "title", - Some(&claims("reader")) + Some(&claims("reader")), + None )); // Placeholder omits enabled, so a stale optional type could otherwise // allow the predicate while concrete response projection denies it. @@ -474,7 +651,8 @@ mod tests { &snapshot(&stale, ""), ¤t, "title", - Some(&claims("reader")) + Some(&claims("reader")), + None )); } @@ -512,10 +690,45 @@ mod tests { &snapshot, &schema, "title", - Some(&claims("reader")) + Some(&claims("reader")), + None )); } + fn test_condition_context(tenant: Option<&str>) -> ConditionContext { + test_condition_context_for_role(tenant, "reader") + } + + fn test_condition_context_for_role(tenant: Option<&str>, role: &str) -> ConditionContext { + let schema = schema("title: text required @field_access(read: [\"reader\"])"); + let snapshot = snapshot(&schema, ""); + let reader = claims(role); + let resource = build_query_resource_placeholder(&schema, tenant).unwrap(); + let request = Request::new( + principal_uid(&reader).unwrap(), + super::super::namespace::field_read_action_uid("Notice", "title") + .parse::() + .unwrap(), + resource.uid(), + Context::from_pairs([( + "resource_is_placeholder".into(), + RestrictedExpression::new_bool(true), + )]) + .unwrap(), + Some(&snapshot.schema), + ) + .unwrap(); + let mut entities = + build_principal_entities(&reader, &snapshot.role_ranks, &snapshot.principal_claims) + .unwrap(); + entities.push(resource); + ConditionContext { + request, + entities: Entities::from_entities(entities, Some(&snapshot.schema)).unwrap(), + tenant_bound: tenant.is_some(), + } + } + #[test] fn custom_expression_proof_rejects_aliases_context_and_unknown_forms() { for condition in [ @@ -524,15 +737,19 @@ mod tests { r#"Notice::"some_record" has title"#, r#"Notice::"some_record".title == "secret""#, r#"Notice::"some_record" in principal"#, - r#"if principal.role_rank == 0 then true else resource has title"#, - r#"principal.role_rank == 0 || !context.resource_is_placeholder"#, + r#"if principal.role_rank > 0 then true else resource has title"#, + r#"principal.role_rank > 0 || !context.resource_is_placeholder"#, ] { let policy: Policy = format!("forbid(principal, action, resource) when {{ {condition} }};") .parse() .unwrap(); assert!( - !custom_field_policy_is_record_independent(&policy, &policy.to_json().unwrap()), + !custom_field_policy_is_constant( + &policy, + &policy.to_json().unwrap(), + &test_condition_context(None) + ), "{condition}" ); } @@ -550,9 +767,126 @@ mod tests { "# .parse() .unwrap(); - assert!(custom_field_policy_is_record_independent( + assert!(custom_field_policy_is_constant( &policy, - &policy.to_json().unwrap() + &policy.to_json().unwrap(), + &test_condition_context(None) + )); + } + + #[test] + fn tenant_boolean_proof_preserves_error_order_and_positive_presence_guards() { + let context = test_condition_context(None); + for (condition, expected) in [ + ( + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared" && false"#, + true, + ), + ( + r#"resource._tenant == Forge::Tenant::"shared" && false"#, + false, + ), + ( + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared" && principal.missing == "x" && false"#, + false, + ), + (r#"principal.missing == "x" && false"#, false), + (r#"false && principal.missing == "x""#, true), + ( + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared" && false && principal.missing == "x""#, + true, + ), + ( + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared" || true"#, + true, + ), + ( + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared""#, + false, + ), + ] { + let policy: Policy = + format!("forbid(principal, action, resource) when {{ {condition} }};") + .parse() + .unwrap(); + assert_eq!( + custom_field_policy_is_constant(&policy, &policy.to_json().unwrap(), &context), + expected, + "{condition}" + ); + } + } + + #[test] + fn accepted_boolean_proofs_agree_with_cedar_across_the_unscoped_tenant_domain() { + let conditions = [ + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared" && !(principal in Forge::Group::"platform_admin")"#, + r#"resource has _tenant && resource._tenant == Forge::Tenant::"shared" || principal in Forge::Group::"platform_admin""#, + r#"!(resource has _tenant && resource._tenant == Forge::Tenant::"shared" && principal in Forge::Group::"platform_admin")"#, + r#"if resource has _tenant then resource._tenant == Forge::Tenant::"shared" && false else false"#, + r#"if resource has _tenant && resource._tenant == Forge::Tenant::"shared" then principal in Forge::Group::"platform_admin" else principal in Forge::Group::"platform_admin""#, + r#"if principal in Forge::Group::"platform_admin" then false else resource has _tenant && resource._tenant == Forge::Tenant::"shared" && false"#, + ]; + let mut certified = 0; + for role in ["reader", "platform_admin"] { + for condition in conditions { + let policy: Policy = + format!("permit(principal, action, resource) when {{ {condition} }};") + .parse() + .unwrap(); + let ast = policy.to_json().unwrap(); + let body = &ast["conditions"][0]["body"]; + if let Some(ConditionTruth::Known(expected)) = + prove_condition(body, &test_condition_context_for_role(None, role), false) + { + certified += 1; + for tenant in [None, Some("shared"), Some("allowed"), Some("another")] { + assert_eq!( + evaluate_boolean(body, &test_condition_context_for_role(tenant, role)), + Some(expected), + "{role}, {tenant:?}, {condition}" + ); + } + } + } + } + assert_eq!( + certified, 9, + "the table must exercise accepted proofs, not vacuously skip every case" + ); + } + + #[test] + fn tenant_bound_proof_rejects_identity_translation_and_certifies_all_tenant_values() { + let schema = schema("title: text required @field_access(read: [\"reader\"])"); + let snapshot = snapshot( + &schema, + r#"forbid(principal, action, resource) when { resource has _tenant && resource._tenant == Forge::Tenant::"shared" };"#, + ); + for tenant in ["shared", "allowed"] { + assert!(field_read_is_record_independent( + &snapshot, + &schema, + "title", + Some(&claims("reader")), + Some(tenant) + )); + } + for tenant in ["", "bad\\nidentity", "bad\"identity"] { + assert!(!field_read_is_record_independent( + &snapshot, + &schema, + "title", + Some(&claims("reader")), + Some(tenant) + )); + } + assert!(!field_read_is_record_independent( + &snapshot, + &schema, + "title", + Some(&claims("reader")), + None )); } diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 68cf314..f608713 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -9,8 +9,7 @@ use schema_forge_core::types::{DynamicValue, EntityId, SchemaDefinition, SchemaN use super::context::ForgeGraphqlContext; use super::input_types::filter_input_to_filter; use crate::access::{ - check_schema_access, filter_entity_fields, inject_tenant_scope, AccessAction, - FieldFilterDirection, + check_schema_access, filter_entity_fields, AccessAction, FieldFilterDirection, QueryTenantScope, }; use crate::authz::{authorize, namespace::ActionVerb}; use crate::error::ForgeError; @@ -112,6 +111,8 @@ pub async fn resolve_list_entities<'a>( let live_definition = request_schema(gql_ctx, schema_name).await?; let schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); + let query_scope = + QueryTenantScope::from_request(claims, &gql_ctx.state.tenant_config, schema_def); check_schema_access( &gql_ctx.state.policy_store, @@ -144,8 +145,14 @@ pub async fn resolve_list_entities<'a>( message: errors.join("; "), }) })?; - authorize_query_filter(&gql_ctx.state.policy_store, claims, schema_def, &filter) - .map_err(forge_error_to_gql)?; + authorize_query_filter( + &gql_ctx.state.policy_store, + claims, + schema_def, + &filter, + &query_scope, + ) + .map_err(forge_error_to_gql)?; validate_filter(&filter, schema_def).map_err(|errors| { forge_error_to_gql(ForgeError::InvalidQuery { message: errors @@ -178,6 +185,7 @@ pub async fn resolve_list_entities<'a>( claims, schema_def, &path, + &query_scope, ) .map_err(forge_error_to_gql)?; query = query.with_sort(path, order); @@ -188,7 +196,7 @@ pub async fn resolve_list_entities<'a>( } // Inject tenant scope - inject_tenant_scope(&mut query, claims, &gql_ctx.state.tenant_config, schema_def); + query_scope.inject(&mut query); let result = gql_ctx .state diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 634d15e..ec317d3 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -25,7 +25,7 @@ use crate::access::{ inject_audit_columns_on_create, inject_audit_columns_on_update, inject_owner_on_create, inject_tenant_on_create, inject_tenant_scope, schema_permissions, stamp_root_tenant, strip_owner_on_update, strip_tenant_on_update, AccessAction, EntityPermissions, - FieldFilterDirection, OptionalClaims, SchemaPermissions, + FieldFilterDirection, OptionalClaims, QueryTenantScope, SchemaPermissions, }; use crate::actor::ForgeActor; use crate::authz::{authorize, namespace::ActionVerb}; @@ -1554,6 +1554,24 @@ fn coerce_json_filter_value( } } +async fn fetch_query_tenant_scope( + forge: &acton_service::prelude::ActorHandle, + claims: Option<&Claims>, + schema: &SchemaDefinition, +) -> Result { + let (tx, rx) = oneshot::channel(); + forge + .send(GetTenantConfig { + reply: ReplyChannel::new(tx), + }) + .await; + Ok(QueryTenantScope::from_request( + claims, + &ask_forge(rx).await?, + schema, + )) +} + /// Shared collection query boundary: validate caller paths before storage can /// expose a hidden value through membership, count, or ordering. Related target /// policies are not evaluated by @@ -1563,6 +1581,7 @@ pub(crate) fn authorize_query_path( claims: Option<&Claims>, schema: &SchemaDefinition, path: &FieldPath, + scope: &QueryTenantScope, ) -> Result<(), ForgeError> { let denied = || ForgeError::Forbidden { message: "Not authorized to filter this field.".into(), @@ -1580,8 +1599,14 @@ pub(crate) fn authorize_query_path( })?; if field.is_hidden() || (field.field_access().is_some() - && !crate::authz::engine::authorize_query_field(store, claims, schema, path.root()) - .is_ok_and(|decision| decision.is_allow())) + && !crate::authz::engine::authorize_query_field( + store, + claims, + schema, + path.root(), + scope, + ) + .is_ok_and(|decision| decision.is_allow())) { return Err(denied()); } @@ -1594,15 +1619,16 @@ pub(crate) fn authorize_query_filter( claims: Option<&Claims>, schema: &SchemaDefinition, filter: &Filter, + scope: &QueryTenantScope, ) -> Result<(), ForgeError> { match filter { Filter::And { filters } | Filter::Or { filters } => { for filter in filters { - authorize_query_filter(store, claims, schema, filter)?; + authorize_query_filter(store, claims, schema, filter, scope)?; } Ok(()) } - Filter::Not { filter } => authorize_query_filter(store, claims, schema, filter), + Filter::Not { filter } => authorize_query_filter(store, claims, schema, filter, scope), Filter::Eq { path, .. } | Filter::Ne { path, .. } | Filter::Gt { path, .. } @@ -1611,7 +1637,7 @@ pub(crate) fn authorize_query_filter( | Filter::Lte { path, .. } | Filter::Contains { path, .. } | Filter::StartsWith { path, .. } - | Filter::In { path, .. } => authorize_query_path(store, claims, schema, path), + | Filter::In { path, .. } => authorize_query_path(store, claims, schema, path, scope), _ => Err(ForgeError::Forbidden { message: "Not authorized to filter this field.".into(), }), @@ -1756,7 +1782,8 @@ async fn execute_entity_query( .actor::() .expect("ForgeActor not registered"); - // Inject tenant scope filter + // Resolve configuration for related display/collection queries. The primary + // query already carries the exact scope used for its field preflight. let (tx, rx) = oneshot::channel(); forge .send(GetTenantConfig { @@ -1764,7 +1791,6 @@ async fn execute_entity_query( }) .await; let tenant_config = ask_forge(rx).await?; - inject_tenant_scope(query, claims, &tenant_config, schema_def); // NOTE: the client `fields` projection is deliberately NOT pushed into the // DB query as a column selection. Record-level authorization @@ -3242,6 +3268,8 @@ pub async fn list_entities( } } + let query_scope = fetch_query_tenant_scope(&forge, claims.as_ref(), &schema_def).await?; + // Build a query let mut query = schema_forge_core::query::Query::new(schema_def.id.clone()); @@ -3268,7 +3296,13 @@ pub async fn list_entities( let sort_clauses = parse_sort_param(sort_str).map_err(|e| ForgeError::InvalidQuery { message: e })?; for (path, order) in sort_clauses { - authorize_query_path(&policy_store, claims.as_ref(), &schema_def, &path)?; + authorize_query_path( + &policy_store, + claims.as_ref(), + &schema_def, + &path, + &query_scope, + )?; query = query.with_sort(path, order); } } @@ -3279,7 +3313,7 @@ pub async fn list_entities( message: errors.join("; "), })?; if let Some(f) = &filter { - authorize_query_filter(&policy_store, claims.as_ref(), &schema_def, f)?; + authorize_query_filter(&policy_store, claims.as_ref(), &schema_def, f, &query_scope)?; validate_filter(f, &schema_def).map_err(|errors| ForgeError::InvalidQuery { message: errors .iter() @@ -3307,6 +3341,7 @@ pub async fn list_entities( // ?count=false opts out of the default-on total_count computation. let include_total = parse_truthy_flag(¶ms, "count"); + query_scope.inject(&mut query); let response = execute_entity_query( &state, &schema_def, @@ -3401,6 +3436,8 @@ pub async fn query_entities( } } + let query_scope = fetch_query_tenant_scope(&forge, claims.as_ref(), &schema_def).await?; + // Build a query let mut query = schema_forge_core::query::Query::new(schema_def.id.clone()); @@ -3417,7 +3454,13 @@ pub async fn query_entities( let path = FieldPath::parse(&clause.field).map_err(|e| ForgeError::InvalidQuery { message: format!("invalid sort field '{}': {e}", clause.field), })?; - authorize_query_path(&policy_store, claims.as_ref(), &schema_def, &path)?; + authorize_query_path( + &policy_store, + claims.as_ref(), + &schema_def, + &path, + &query_scope, + )?; let order = match clause.order.as_deref() { Some("desc") => SortOrder::Descending, Some("asc") | None => SortOrder::Ascending, @@ -3438,7 +3481,13 @@ pub async fn query_entities( message: errors.join("; "), } })?; - authorize_query_filter(&policy_store, claims.as_ref(), &schema_def, &filter)?; + authorize_query_filter( + &policy_store, + claims.as_ref(), + &schema_def, + &filter, + &query_scope, + )?; validate_filter(&filter, &schema_def).map_err(|errors| ForgeError::InvalidQuery { message: errors .iter() @@ -3472,6 +3521,7 @@ pub async fn query_entities( None }; + query_scope.inject(&mut query); let response = execute_entity_query( &state, &schema_def, diff --git a/crates/schema-forge-acton/src/routes/export.rs b/crates/schema-forge-acton/src/routes/export.rs index d489fa3..71cbbf9 100644 --- a/crates/schema-forge-acton/src/routes/export.rs +++ b/crates/schema-forge-acton/src/routes/export.rs @@ -19,7 +19,7 @@ //! workbook; ZIP is async-only because the multi-member archive (and any bundled //! file blobs) is staged and buffered before upload. //! -//! Tenant injection ([`inject_tenant_scope`]) and per-field read stripping +//! Tenant injection ([`crate::access::inject_tenant_scope`]) and per-field read stripping //! ([`filter_entity_fields`]) run identically to the normal query path, so the //! file can never contain a row or a field the caller could not already read one //! record at a time. Every request emits the AU-2 exfiltration audit trail @@ -47,8 +47,8 @@ use tokio::sync::oneshot; use tracing::instrument; use crate::access::{ - check_export_access, filter_entity_fields, inject_tenant_scope, FieldFilterDirection, - OptionalClaims, + check_export_access, filter_entity_fields, FieldFilterDirection, OptionalClaims, + QueryTenantScope, }; use crate::actor::ForgeActor; use crate::config::SchemaForgeConfig; @@ -373,6 +373,7 @@ fn parse_export_filter( claims: Option<&acton_service::middleware::Claims>, schema: &SchemaDefinition, filter_json: Option<&serde_json::Value>, + scope: &QueryTenantScope, ) -> Result, ForgeError> { filter_json .map(|json| { @@ -382,7 +383,7 @@ fn parse_export_filter( message: errors.join("; "), } })?; - crate::routes::entities::authorize_query_filter(store, claims, schema, &filter)?; + crate::routes::entities::authorize_query_filter(store, claims, schema, &filter, scope)?; validate_filter(&filter, schema).map_err(|errors| ForgeError::InvalidQuery { message: errors .iter() @@ -508,11 +509,14 @@ pub async fn prepare_export( record_access_policy, } = ctx; let claims = *claims; + let query_scope = QueryTenantScope::from_request(claims, tenant_config, schema_def); // Build the query (an export is a query with no page limit). Cap the read at // max_rows + 1 so an over-cap result is detectable without draining the table. let mut query = Query::new(schema_def.id.clone()).without_total_count(); - if let Some(filter) = parse_export_filter(policy_store, claims, schema_def, filter_json)? { + if let Some(filter) = + parse_export_filter(policy_store, claims, schema_def, filter_json, &query_scope)? + { query = query.with_filter(filter); } let probe_limit = usize::try_from(max_rows) @@ -521,7 +525,7 @@ pub async fn prepare_export( query = query.with_limit(probe_limit); // Same tenant injection as the query path. - inject_tenant_scope(&mut query, claims, tenant_config, schema_def); + query_scope.inject(&mut query); // Execute. let (tx, rx) = oneshot::channel(); @@ -846,11 +850,20 @@ pub async fn export_entities( } } + let (tx, rx) = oneshot::channel(); + forge + .send(GetTenantConfig { + reply: ReplyChannel::new(tx), + }) + .await; + let tenant_config = ask_forge(rx).await?; + let query_scope = QueryTenantScope::from_request(claims.as_ref(), &tenant_config, &schema_def); parse_export_filter( &policy_store, claims.as_ref(), &schema_def, body.filter.as_ref(), + &query_scope, )?; // Per-subject bulk-export rate limit. Export is an exfiltration surface, so a @@ -898,13 +911,6 @@ pub async fn export_entities( // Resolve the tenant config + record-access policy once; both the sync and // async paths need them, and they are the same inputs the query path uses. - let (tx, rx) = oneshot::channel(); - forge - .send(GetTenantConfig { - reply: ReplyChannel::new(tx), - }) - .await; - let tenant_config = ask_forge(rx).await?; let (tx, rx) = oneshot::channel(); forge diff --git a/crates/schema-forge-acton/tests/tenant_query_field_authorization.rs b/crates/schema-forge-acton/tests/tenant_query_field_authorization.rs new file mode 100644 index 0000000..edb0c2a --- /dev/null +++ b/crates/schema-forge-acton/tests/tenant_query_field_authorization.rs @@ -0,0 +1,601 @@ +//! Real storage regressions for tenant-bound field query proofs. +#![cfg(feature = "test-surrealdb")] + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{ + body::Body, + http::{Request, StatusCode}, + Router, +}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + authz::{PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks}, + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + routes::forge_routes, + storage::StorageRegistry, + ForgeActor, +}; +use schema_forge_backend::{Entity, EntityStore, SchemaBackend, TenantConfig}; +use schema_forge_core::{migration::DiffEngine, types::DynamicValue}; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; +use tokio::sync::oneshot; +use tower::ServiceExt; + +struct Fixture { + app: Router, + state: acton_service::state::AppState, + schemas: Vec, + #[cfg(feature = "graphql")] + backend: Arc, + store: Arc, + tenant_config: Option, + allowed: String, + shared: String, +} + +impl Fixture { + async fn new() -> Self { + Self::with_options(true, "").await + } + + async fn with_options(enforce_tenancy: bool, extra_policy: &str) -> Self { + let schemas = schema_forge_dsl::parse( + r#" + @tenant(root) + @access(read: ["member"], write: ["member"], delete: ["member"]) + schema Organization { name: text required @field_access(write: ["member"]) } + @tenant(parent: "Organization") + @export(formats: [csv], bundle_files: false, max_rows: 100) + @access(read: ["member"], write: ["member"], delete: ["member"]) + schema Contact { + organization: -> Organization required + name: text required @field_access(write: ["member"]) @exportable + } + "#, + ) + .unwrap(); + let backend = Arc::new( + schema_forge_surrealdb::test_support::connect("tenant_proof", "tenant_proof") + .await + .unwrap(), + ); + for schema in &schemas { + backend + .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(schema).await.unwrap(); + } + let mut tenants = Vec::new(); + for name in ["Allowed", "Shared"] { + let root = Entity::new( + schemas[0].name.clone(), + BTreeMap::from([("name".into(), DynamicValue::Text(name.into()))]), + ); + backend.create(&root).await.unwrap(); + let contact = Entity::new( + schemas[1].name.clone(), + BTreeMap::from([ + ("organization".into(), DynamicValue::Ref(root.id.clone())), + ("name".into(), DynamicValue::Text(format!("Ada {name}"))), + ("_tenant".into(), DynamicValue::Text(root.id.to_string())), + ]), + ); + backend.create(&contact).await.unwrap(); + tenants.push(root.id.to_string()); + } + let policies = tempfile::tempdir().unwrap(); + std::fs::write( + policies.path().join("custom.cedar"), + format!( + r#" + @id("shared_tenant_inert") + forbid(principal, action, resource) when {{ + resource has "_tenant" + && resource["_tenant"] == Forge::Tenant::"{}" + && !(principal in Forge::Group::"platform_admin") + }}; + permit(principal, action == Action::"ExportContact", resource is Contact); + "#, + tenants[1] + ), + ) + .unwrap(); + let mut custom = std::fs::read_to_string(policies.path().join("custom.cedar")).unwrap(); + custom.push_str(extra_policy); + std::fs::write(policies.path().join("custom.cedar"), custom).unwrap(); + let store = Arc::new(PolicyStore::new( + PolicyStoreSnapshot::from_schemas( + &schemas, + Some(policies.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(), + )); + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .with_actor::() + .with_actor::() + .build(); + let tenant_config = enforce_tenancy.then(|| TenantConfig::from_schemas(&schemas).unwrap()); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: schemas + .iter() + .map(|schema| (schema.name.to_string(), schema.clone())) + .collect(), + backend: backend.clone(), + tenant_config: tenant_config.clone(), + record_access_policy: None, + hook_dispatcher: None, + storage_registry: StorageRegistry::default(), + policy_store: Some(store.clone()), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + rx.await.unwrap(); + Self { + app: forge_routes().with_state(service.state().clone()), + state: service.state().clone(), + schemas, + #[cfg(feature = "graphql")] + backend, + store, + tenant_config, + allowed: tenants.remove(0), + shared: tenants.remove(0), + } + } + + fn claims(&self, admin: bool, tenants: &[&str]) -> Claims { + Claims { + sub: "user:tenant-proof".into(), + roles: vec![if admin { "platform_admin" } else { "member" }.into()], + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: HashMap::from([( + "tenant_chain".into(), + Value::Array( + tenants + .iter() + .map(|tenant| json!({"schema":"Organization","entity_id":tenant})) + .collect(), + ), + )]), + } + } + + async fn get(&self, path: &str, claims: Claims) -> (StatusCode, Value) { + let mut request = Request::get(path).body(Body::empty()).unwrap(); + request.extensions_mut().insert(claims); + let response = self.app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap()) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn allowed_tenant_can_filter_and_sort_the_reported_field() { + let fixture = Fixture::new().await; + for query in ["name__startswith=Ada", "sort=name"] { + let (status, body) = fixture + .get( + &format!("/schemas/Contact/entities?{query}&resolve=false"), + fixture.claims(false, &[&fixture.allowed]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["entities"].as_array().unwrap().len(), 1, "{body}"); + assert_eq!( + body["entities"][0]["fields"]["name"], "Ada Allowed", + "{body}" + ); + assert_eq!(body["total_count"], 1, "{body}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn admin_can_filter_and_sort_across_all_tenants() { + let fixture = Fixture::new().await; + for query in ["name__startswith=Ada", "sort=name"] { + let (status, body) = fixture + .get( + &format!("/schemas/Contact/entities?{query}&resolve=false"), + fixture.claims(true, &[]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["entities"].as_array().unwrap().len(), 2, "{body}"); + assert_eq!(body["total_count"], 2, "{body}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_forbidden_candidate_including_an_ancestor_denies_the_entire_predicate() { + let fixture = Fixture::new().await; + for tenants in [ + vec![fixture.shared.as_str()], + vec![fixture.shared.as_str(), fixture.allowed.as_str()], + ] { + for query in ["name__startswith=Ada", "sort=name"] { + let (status, body) = fixture + .get( + &format!("/schemas/Contact/entities?{query}&resolve=false"), + fixture.claims(false, &tenants), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn roots_bind_the_tenant_to_the_enforced_record_id() { + let fixture = Fixture::new().await; + let (status, body) = fixture + .get( + "/schemas/Organization/entities?sort=name&resolve=false", + fixture.claims(false, &[&fixture.allowed]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["entities"].as_array().unwrap().len(), 1, "{body}"); + assert_eq!(body["entities"][0]["id"], fixture.allowed, "{body}"); + let (status, body) = fixture + .get( + "/schemas/Organization/entities?sort=name", + fixture.claims(false, &[&fixture.shared, &fixture.allowed]), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn disabled_enforcement_or_empty_scope_cannot_certify_a_tenant_from_claims() { + for enabled in [false, true] { + let fixture = Fixture::with_options(enabled, "").await; + let tenants = if enabled { + vec![] + } else { + vec![fixture.allowed.as_str()] + }; + let (status, body) = fixture + .get( + "/schemas/Contact/entities?sort=name", + fixture.claims(false, &tenants), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn actual_record_fences_still_require_concrete_field_authorization() { + let fixture = Fixture::with_options( + true, + r#" + forbid(principal, action == Action::"ReadFieldContact_name", resource is Contact) + when { resource.name == "Ada Allowed" }; + "#, + ) + .await; + for admin in [false, true] { + let (status, body) = fixture + .get( + "/schemas/Contact/entities?sort=name", + fixture.claims(admin, &[&fixture.allowed]), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn json_queries_share_the_same_scope_for_proof_and_storage() { + let fixture = Fixture::new().await; + for (tenants, expected) in [ + (vec![fixture.allowed.as_str()], StatusCode::OK), + ( + vec![fixture.shared.as_str(), fixture.allowed.as_str()], + StatusCode::FORBIDDEN, + ), + ] { + let mut request = Request::post("/schemas/Contact/entities/query") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "filter":{"op":"startswith","field":"name","value":"Ada"}, + "sort":[{"field":"name","order":"asc"}],"resolve":false + }) + .to_string(), + )) + .unwrap(); + request + .extensions_mut() + .insert(fixture.claims(false, &tenants)); + let response = fixture.app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), expected); + if expected == StatusCode::OK { + let body: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .unwrap(); + assert_eq!(body["entities"].as_array().unwrap().len(), 1, "{body}"); + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn export_acceptance_and_materialization_recheck_the_current_policy_and_scope() { + use schema_forge_acton::routes::export::{prepare_export, ExportContext}; + let fixture = Fixture::new().await; + let filter = json!({"op":"startswith","field":"name","value":"Ada"}); + for (tenants, expected) in [ + (vec![fixture.allowed.as_str()], StatusCode::OK), + ( + vec![fixture.shared.as_str(), fixture.allowed.as_str()], + StatusCode::FORBIDDEN, + ), + ] { + let mut request = Request::post("/schemas/Contact/entities/export") + .header("content-type", "application/json") + .body(Body::from( + json!({"format":"csv","filter":filter,"fields":["name"]}).to_string(), + )) + .unwrap(); + request + .extensions_mut() + .insert(fixture.claims(false, &tenants)); + let response = fixture.app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), expected); + if expected == StatusCode::OK { + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let csv = std::str::from_utf8(&bytes).unwrap(); + assert!(csv.contains("Ada Allowed"), "{csv}"); + assert!(!csv.contains("Ada Shared"), "{csv}"); + } + } + // Async execution uses this same public materialization boundary. A policy + // change after acceptance must invalidate the previously accepted filter. + let claims = fixture.claims(false, &[&fixture.allowed]); + let forge = fixture.state.actor::().unwrap(); + let context = ExportContext { + forge: &forge, + claims: Some(&claims), + tenant_config: &fixture.tenant_config, + policy_store: &fixture.store, + record_access_policy: &None, + }; + assert!( + prepare_export(&context, &fixture.schemas[1], Some(&filter), None, 100) + .await + .is_ok() + ); + let policies = tempfile::tempdir().unwrap(); + std::fs::write( + policies.path().join("deny.cedar"), + r#"forbid(principal, action == Action::"ReadFieldContact_name", resource is Contact);"#, + ) + .unwrap(); + fixture.store.swap( + PolicyStoreSnapshot::from_schemas( + &fixture.schemas, + Some(policies.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(), + ); + assert!(matches!( + prepare_export(&context, &fixture.schemas[1], Some(&filter), None, 100).await, + Err(schema_forge_acton::error::ForgeError::Forbidden { .. }) + )); +} + +#[derive(Debug, Default)] +struct MemoryArtifacts(std::sync::Mutex>>); + +#[async_trait::async_trait] +impl schema_forge_acton::storage::ExportArtifactStore for MemoryArtifacts { + async fn put( + &self, + key: &str, + bytes: Vec, + _: &str, + ) -> Result<(), schema_forge_acton::storage::StorageError> { + self.0.lock().unwrap().insert(key.into(), bytes); + Ok(()) + } + + async fn presign_get( + &self, + key: &str, + _: Option, + ) -> Result { + Ok(format!("https://example.test/{key}")) + } + + async fn get(&self, key: &str) -> Result, schema_forge_acton::storage::StorageError> { + self.0.lock().unwrap().get(key).cloned().ok_or_else(|| { + schema_forge_acton::storage::StorageError::Request("missing artifact".into()) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn async_export_acceptance_and_execution_retain_the_complete_tenant_scope() { + use schema_forge_acton::export_job::{ + ExportJobSpec, ExportJobStatus, GetExportJob, StartExportJob, + }; + let fixture = Fixture::new().await; + let artifacts = Arc::new(MemoryArtifacts::default()); + let jobs = fixture + .state + .actor::() + .unwrap(); + for (tenants, success) in [ + (vec![fixture.allowed.as_str()], true), + ( + vec![fixture.shared.as_str(), fixture.allowed.as_str()], + false, + ), + ] { + let claims = fixture.claims(false, &tenants); + let filter = json!({"op":"startswith","field":"name","value":"Ada"}); + let mut request = Request::post("/schemas/Contact/entities/export") + .header("content-type", "application/json") + .body(Body::from( + json!({"format":"csv","filter":filter,"async":true}).to_string(), + )) + .unwrap(); + request.extensions_mut().insert(claims.clone()); + let response = fixture.app.clone().oneshot(request).await.unwrap(); + // No S3 registry is configured. Passing preflight reaches that distinct + // storage gate; a forbidden tenant is refused before job acceptance. + assert_eq!( + response.status(), + if success { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::FORBIDDEN + } + ); + + let job_id = schema_forge_acton::export_job::new_job_id(); + let object_key = format!("exports/{job_id}.csv"); + jobs.send(StartExportJob::new(ExportJobSpec { + job_id: job_id.clone(), + schema_name: "Contact".into(), + schema_def: fixture.schemas[1].clone(), + format: schema_forge_core::types::ExportFormat::Csv, + filter: Some(filter), + fields: Some(vec!["name".into()]), + max_rows: 100, + bundle_files: false, + claims: Some(claims), + tenant_config: fixture.tenant_config.clone(), + policy_store: fixture.store.clone(), + record_access_policy: None, + forge: fixture.state.actor::().unwrap().clone(), + store: artifacts.clone(), + object_key: object_key.clone(), + audit_logger: None, + subject: Some("user:tenant-proof".into()), + })) + .await; + let record = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let (tx, rx) = oneshot::channel(); + jobs.send(GetExportJob { + job_id: job_id.clone(), + reply: ReplyChannel::new(tx), + }) + .await; + if let Some(record) = rx.await.unwrap() { + if matches!( + record.status, + ExportJobStatus::Complete | ExportJobStatus::Failed + ) { + break record; + } + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert_eq!( + record.status, + if success { + ExportJobStatus::Complete + } else { + ExportJobStatus::Failed + }, + "{record:?}" + ); + let objects = artifacts.0.lock().unwrap(); + if success { + assert_eq!(record.row_count, Some(1)); + let csv = std::str::from_utf8(objects.get(&object_key).unwrap()).unwrap(); + assert!(csv.contains("Ada Allowed"), "{csv}"); + assert!(!csv.contains("Ada Shared"), "{csv}"); + } else { + assert!(!objects.contains_key(&object_key)); + } + } +} + +#[cfg(feature = "graphql")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_uses_the_same_complete_tenant_domain() { + use schema_forge_acton::{ + graphql::{context::ForgeGraphqlContext, schema_builder::build_graphql_schema}, + state::{ForgeState, SchemaRegistry}, + }; + let fixture = Fixture::new().await; + let registry = SchemaRegistry::new(); + for schema in &fixture.schemas { + registry + .insert(schema.name.to_string(), schema.clone()) + .await; + } + let schema = build_graphql_schema(&fixture.schemas).unwrap(); + for (admin, tenants, allowed, count) in [ + (false, vec![fixture.allowed.as_str()], true, 1), + (true, vec![], true, 2), + ( + false, + vec![fixture.shared.as_str(), fixture.allowed.as_str()], + false, + 0, + ), + ] { + let context = ForgeGraphqlContext { + state: ForgeState { + registry: registry.clone(), + backend: fixture.backend.clone(), + tenant_config: fixture.tenant_config.clone(), + record_access_policy: None, + policy_store: fixture.store.clone(), + graphql_schema: schema_forge_acton::graphql::empty_graphql_schema(), + auth_store: None, + webhook_dispatcher: None, + storage_registry: StorageRegistry::default(), + }, + app_state: fixture.state.clone(), + claims: Some(fixture.claims(admin, &tenants)), + }; + let response = schema.execute(async_graphql::Request::new(r#"{ contacts(filter: {name_starts_with: "Ada"}, sort: [{field: name, order: ASC}]) { items { name } } }"#).data(context)).await; + assert_eq!(response.errors.is_empty(), allowed, "{:?}", response.errors); + if allowed { + assert_eq!( + response.data.into_json().unwrap()["contacts"]["items"] + .as_array() + .unwrap() + .len(), + count + ); + } + } +} diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index 673528b..ce8bac3 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.52.2" +version = "0.52.3" edition = "2021" [[bin]] @@ -13,7 +13,7 @@ schema-forge-dsl = { path = "../schema-forge-dsl" } schema-forge-backend = { version = "0.23.0", path = "../schema-forge-backend", optional = true } schema-forge-surrealdb = { path = "../schema-forge-surrealdb", optional = true } schema-forge-postgres = { path = "../schema-forge-postgres", optional = true } -schema-forge-acton = { version = "0.50.1", path = "../schema-forge-acton", optional = true } +schema-forge-acton = { version = "0.50.2", path = "../schema-forge-acton", optional = true } clap = { version = "4", features = ["derive", "env", "string"] } clap_complete = "4" serde = { version = "1", features = ["derive"] } diff --git a/docs/custom-policy-context.md b/docs/custom-policy-context.md index 8a12a48..a8c79d8 100644 --- a/docs/custom-policy-context.md +++ b/docs/custom-policy-context.md @@ -99,7 +99,11 @@ Fields with `@field_access` require an allowed ReadField check, compatible concr For a custom policy that can match, the proof supports an unrestricted resource head or the current schema type, and conditions built from principal/action values, literals, direct principal attributes, and supported Boolean, comparison, arithmetic, and set operations. A principal-only suspension fence is therefore safe to evaluate before querying. Independence grants no permission: Cedar still evaluates the complete original policy set, so a suspension fence that matches, a constant forbid, or an evaluation error still denies access. -Record-specific resource equality or membership heads, `resource` or `context` references anywhere in conditions, explicit entity attribute/tag reads, nested or dynamic attribute lookups, extensions, and unrecognized expression forms fail closed with 403 when their principal head can match. This is a conservative syntax proof, not general partial evaluation: a record-dependent branch is still refused even if a principal-only condition would short-circuit it for this request. In particular, `context.resource_is_placeholder` cannot certify concrete readability because the query preflight uses `true` while returned records use `false`. A synthetic value cannot prove that all concrete field values are readable. +Tenant-aware queries capture the scope that storage will actually enforce. For child schemas this restricts `_tenant`; tenant roots restrict `id`, from which the concrete Cedar adapter derives `_tenant`. The scope includes the active tenant and its ancestors, and the proof must succeed for every candidate tenant. Direct `_tenant` equality or inequality with entity literals and `resource has "_tenant"` can then be evaluated before querying. A forbid matching any candidate denies the predicate. Disabled tenancy, shared schemas, empty scopes, and administrators supply no tenant bound; raw claims alone never establish one. Query witnesses retain empty resource parents and the preflight context marker. + +The proof also credits ordered Boolean short-circuiting. For an administrator, the reported pattern `resource has "_tenant" && resource._tenant == Forge::Tenant::"shared" && !(principal in Forge::Group::"platform_admin")` is always false: its guarded tenant comparison is a total Boolean and the final principal condition is false. An unguarded optional tenant read cannot receive that proof. Cedar evaluates from left to right, so `error && false` still fails; `false && error` skips the error. Evaluation errors never become permission. + +Record-specific resource equality or membership heads, other resource attributes or identities, relevant `context` references, explicit entity attribute/tag reads, nested or dynamic attribute lookups, extensions, and unrecognized expression forms remain fail-closed when their principal head can match. This is conservative Boolean reasoning, not general partial evaluation. A branch that Cedar demonstrably never evaluates can be ignored; unresolved record dependencies cannot. In particular, `context.resource_is_placeholder` cannot certify concrete readability because the query preflight uses `true` while returned records use `false`. Synthetic field defaults cannot prove that concrete field values are readable. Unrelated action or schema policies do not disable query field access. The same field proof protects REST filters and sorts, GraphQL collection predicates, and synchronous/asynchronous exports. These checks occur before pagination and total counts, including `limit=0` and `count=false`, without scanning records to infer field permissions. diff --git a/docs/plans/tenant-query-proof-256.md b/docs/plans/tenant-query-proof-256.md new file mode 100644 index 0000000..d202213 --- /dev/null +++ b/docs/plans/tenant-query-proof-256.md @@ -0,0 +1,20 @@ +# Issue 256: prove field predicates within enforced tenant scopes + +## Confirmed flow and baseline +REST list/JSON query, GraphQL, and exports authorize caller filter/sort fields before injecting tenant filters. Injection admits the active tenant and every ancestor in the rewritten chain, filtering child `_tenant` or root `id`; administrators, disabled tenancy, empty claims/chains, and shared schemas remain unscoped. The concrete Cedar adapter derives a root's `_tenant` from its ID and gives every concrete resource empty parents. Existing custom field proof rejects any matching resource dependency, including the reported guarded tenant forbid. + +Add real-server HTTP regressions for the exact two-schema example and the reported policy, with administrator and non-shared-tenant callers. Run them against unchanged production code and record the expected 403 failures before implementing. + +## Design approved by root +Preserve the public `inject_tenant_scope` signature. Factor its logic into an internal trusted `QueryTenantScope`, representing Unscoped or the complete finite list of candidate tenant IDs and their root/child storage field. Use the same scope object for preflight proof and filter injection. Obtain the object from the actual configured enforcement boundary, never from claims alone. REST injects the captured scope before shared execution; GraphQL and export pipelines do likewise. Async export acceptance and execution each perform the proof using their resolved configuration and current policy snapshot. + +For every finite candidate tenant, certify custom conditions and evaluate the original complete Cedar policy set on a typed tenant-bound witness with empty parents. Every candidate must Allow without diagnostic errors. Require canonical tenant identities to avoid Cedar parsing translation diverging from raw backend filter values. Root scopes bind `_tenant` from exactly the ID set enforced by storage. + +Extend the conservative EST proof with ordered Boolean reasoning. Evaluate principal/action-only Boolean subexpressions using public `Policy::from_json` and `Authorizer` APIs. Only support resource tenant presence and direct tenant equality/inequality with entity literals. A finite scope proves presence; an unscoped resource needs a positive `has _tenant` fact before reading its optional tenant. Guarded comparisons can be total unknown Booleans, allowing a following known false conjunct to prove the entire condition false for an administrator. Preserve Cedar evaluation order: an unsafe/error-producing left operand cannot be discarded because a right operand is false. Short-circuited right branches need not be evaluated. Reject unsupported residual dependencies and any actual diagnostic errors. + +Keep exact generated-policy AST integrity, action inheritance restrictions, principal membership semantics, concrete schema-shape checks, arbitrary entity alias restrictions, and record-specific resource head denials. Add no resource parents, dependencies, public types, or lint suppressions. + +## Files and validation +Modify access scope injection, private authz proof/engine, shared REST query helpers/callers, GraphQL resolver, export acceptance/materialization, and `docs/custom-policy-context.md`. Add focused unit and real-server regressions covering allowed/shared tenants, ancestor scope containing a forbidden tenant, roots, admin bypass, disabled/unscoped tenancy, true record fences, missing attributes/errors and Boolean order, exact generated rules, exports including execution recheck, and GraphQL. + +Run locked Nextest through the disposable SurrealDB runner, relevant Clippy with warnings denied, formatting/diff checks, and existing query authorization regressions. Root owns independent security review, broader backend validation, versions/lockfile/changelog, signed commits, PR, and release. Public APIs remain unchanged; semver recommendation is patch.