Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion crates/schema-forge-acton/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "schema-forge-acton"
version = "0.50.1"
version = "0.50.2"
edition = "2021"

[dependencies]
Expand Down Expand Up @@ -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"]
128 changes: 83 additions & 45 deletions crates/schema-forge-acton/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,53 +353,89 @@ pub fn inject_tenant_scope(
tenant_config: &Option<TenantConfig>,
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<Filter>,
tenant_ids: Vec<String>,
}

impl QueryTenantScope {
pub(crate) fn from_request(
claims: Option<&Claims>,
tenant_config: &Option<TenantConfig>,
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<TenantRef> = claims
.custom_claim_as::<Vec<TenantRef>>("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 <chain>` here filters by exactly the rows
// the policy would also allow.
let tenant_values: Vec<DynamicValue> = 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<TenantRef> = claims
.custom_claim_as::<Vec<TenantRef>>("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 <chain>` here filters by exactly the rows
// the policy would also allow.
let tenant_values: Vec<DynamicValue> = 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.
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions crates/schema-forge-acton/src/authz/adapters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CedarEntity, AdapterError> {
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<CedarEntity, AdapterError> {
let raw = format!("{}::\"_any\"", schema.name.as_str());
let uid = EntityUid::from_str(&raw).map_err(|e| AdapterError::InvalidIdentifier {
value: raw,
Expand All @@ -323,6 +332,18 @@ pub fn build_resource_placeholder(schema: &SchemaDefinition) -> Result<CedarEnti
}
}

if let Some(tenant) = tenant {
let raw = format!("{TENANT_TYPE}::\"{tenant}\"");
let tenant_uid =
EntityUid::from_str(&raw).map_err(|error| AdapterError::InvalidIdentifier {
value: raw,
detail: error.to_string(),
})?;
attrs.insert(
"_tenant".into(),
RestrictedExpression::new_entity_uid(tenant_uid),
);
}
CedarEntity::new(uid, attrs, HashSet::new()).map_err(|e| AdapterError::UnrepresentableValue {
field: format!("placeholder:{}", schema.name.as_str()),
detail: e.to_string(),
Expand Down
51 changes: 37 additions & 14 deletions crates/schema-forge-acton/src/authz/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ use schema_forge_backend::entity::Entity;
use schema_forge_core::types::SchemaDefinition;

use crate::authz::adapters::{
action_entity_uid, build_principal_entities, build_resource_entity, build_resource_placeholder,
principal_uid, AdapterError,
action_entity_uid, build_principal_entities, build_query_resource_placeholder,
build_resource_entity, build_resource_placeholder, principal_uid, AdapterError,
};
use crate::authz::namespace::{
field_read_action_uid, field_write_action_uid, ActionVerb, PRINCIPAL_TYPE,
Expand Down Expand Up @@ -341,27 +341,44 @@ pub(crate) fn authorize_query_field(
claims: Option<&Claims>,
schema: &SchemaDefinition,
field_name: &str,
scope: &crate::access::QueryTenantScope,
) -> Result<AuthzDecision, AuthzError> {
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(
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading