use crate::{
error::{FraiseQLError, Result},
runtime::{classify_field_access, field_filter::FieldAccessResult},
schema::{CompiledSchema, FieldDenyPolicy, SessionVariableSource, SessionVariablesConfig},
security::{ENRICHED_NAMESPACE_PREFIX, SecurityContext},
};
pub(in super::super) fn enforce_enrichment_resolved(
schema: &crate::schema::CompiledSchema,
security_context: Option<&SecurityContext>,
) -> crate::error::Result<()> {
let Some(ctx) = security_context else {
return Ok(());
};
if ctx.enrichment_mark().is_some() || !schema.declares_enrichment_consumer() {
return Ok(());
}
Err(FraiseQLError::Authorization {
message: "Identity was not resolved for this request. The schema declares an \
enriched-identity consumer, so every authenticated request must resolve \
before dispatch — this one reached the engine unresolved, which means the \
transport it arrived on does not run the enrichment seam."
.to_string(),
action: Some("execute".to_string()),
resource: None,
})
}
pub(in super::super) fn resolve_session_variables(
config: &SessionVariablesConfig,
security_context: &SecurityContext,
) -> Result<Vec<(String, String)>> {
let mut vars: Vec<(String, String)> = Vec::new();
if config.inject_started_at {
vars.push((
fraiseql_db::STARTED_AT_VAR.to_string(),
fraiseql_db::CLOCK_TIMESTAMP_DIRECTIVE.to_string(),
));
}
for mapping in &config.variables {
let value: Option<String> = match &mapping.source {
SessionVariableSource::Jwt { claim } => {
if let Some(v) = security_context.attributes.get(claim.as_str()) {
Some(if let serde_json::Value::String(s) = v {
s.clone()
} else {
v.to_string()
})
} else if claim == "sub" || claim == "user_id" {
Some(security_context.user_id.0.clone())
} else if claim == "tenant_id" {
security_context.tenant_id.as_ref().map(|t| t.0.clone())
} else if claim == "email" {
security_context.email.clone()
} else if claim == "name" || claim == "display_name" {
security_context.display_name.clone()
} else {
None
}
},
SessionVariableSource::Header { header } => {
security_context.attributes.get(header.as_str()).map(|v| {
if let serde_json::Value::String(s) = v {
s.clone()
} else {
v.to_string()
}
})
},
SessionVariableSource::Literal { value } => Some(value.clone()),
SessionVariableSource::Enrichment { field } => {
let key = format!("{ENRICHED_NAMESPACE_PREFIX}{field}");
let Some(v) = security_context.attributes.get(&key) else {
return Err(FraiseQLError::Validation {
message: format!(
"Session variable '{}' maps to enriched field '{field}', which is \
absent from the resolved identity (enrichment did not run, or the \
enrichment query's `map` does not produce it)",
mapping.name
),
path: None,
});
};
Some(if let serde_json::Value::String(s) = v {
s.clone()
} else {
v.to_string()
})
},
};
if let Some(v) = value {
vars.push((mapping.name.clone(), v));
}
}
Ok(vars)
}
pub(in super::super) fn apply_field_rbac_filtering(
schema: &CompiledSchema,
return_type: &str,
projection_fields: Vec<String>,
security_context: &SecurityContext,
) -> Result<FieldAccessResult> {
let no_roles = crate::schema::SecurityConfig::default();
let security_config = schema.security.as_ref().unwrap_or(&no_roles);
if let Some(type_def) = schema.types.iter().find(|t| t.name == return_type) {
return classify_field_access(
security_context,
security_config,
&type_def.fields,
projection_fields,
)
.map_err(|rejected_field| FraiseQLError::Authorization {
message: format!(
"Access denied: field '{rejected_field}' on type '{return_type}' \
requires a scope you do not have"
),
action: Some("read".to_string()),
resource: Some(format!("{return_type}.{rejected_field}")),
});
}
Ok(FieldAccessResult {
projected: projection_fields,
masked: Vec::new(),
})
}
pub(in super::super) fn apply_anonymous_field_rbac_filtering(
schema: &CompiledSchema,
return_type: &str,
projection_fields: &[String],
) -> Result<FieldAccessResult> {
let allow_all = || FieldAccessResult {
projected: projection_fields.to_vec(),
masked: Vec::new(),
};
let Some(type_def) = schema.types.iter().find(|t| t.name == return_type) else {
return Ok(allow_all());
};
let mut projected = Vec::with_capacity(projection_fields.len());
let mut masked = Vec::new();
for name in projection_fields {
projected.push(name.clone());
let Some(field) = type_def.fields.iter().find(|f| &f.name == name) else {
continue;
};
if field.requires_scope.is_none() {
continue;
}
match field.on_deny {
FieldDenyPolicy::Mask => masked.push(name.clone()),
FieldDenyPolicy::Reject => {
return Err(FraiseQLError::Authorization {
message: format!(
"Access denied: field '{name}' on type '{return_type}' \
requires a scope you do not have"
),
action: Some("read".to_string()),
resource: Some(format!("{return_type}.{name}")),
});
},
}
}
Ok(FieldAccessResult { projected, masked })
}
pub(in super::super) fn classify_fields_for_read(
schema: &CompiledSchema,
return_type: &str,
projection_fields: Vec<String>,
security_context: Option<&SecurityContext>,
) -> Result<FieldAccessResult> {
match security_context {
Some(ctx) => apply_field_rbac_filtering(schema, return_type, projection_fields, ctx),
None => apply_anonymous_field_rbac_filtering(schema, return_type, &projection_fields),
}
}
pub(in super::super) fn refuse_unreadable_reference(
schema: &CompiledSchema,
type_name: &str,
field: &crate::schema::FieldDefinition,
security_context: Option<&SecurityContext>,
usage: &str,
) -> Result<()> {
if crate::runtime::can_reference_field(schema.security.as_ref(), field, security_context) {
return Ok(());
}
Err(FraiseQLError::Authorization {
message: format!(
"Access denied: '{type_name}.{}' cannot be used to {usage}: the request may not \
read it",
field.name
),
action: Some("read".to_string()),
resource: Some(format!("{type_name}.{}", field.name)),
})
}
pub(in super::super) fn refuse_unreadable_where(
schema: &CompiledSchema,
type_name: &str,
clause: &crate::db::WhereClause,
security_context: Option<&SecurityContext>,
) -> Result<()> {
use crate::db::WhereClause;
match clause {
WhereClause::And(clauses) | WhereClause::Or(clauses) => clauses
.iter()
.try_for_each(|c| refuse_unreadable_where(schema, type_name, c, security_context)),
WhereClause::Not(inner)
| WhereClause::Typed { inner, .. }
| WhereClause::Guarded { inner, .. } => {
refuse_unreadable_where(schema, type_name, inner, security_context)
},
WhereClause::Field { path, .. } => {
refuse_unreadable_path(schema, type_name, path, security_context)
},
_ => Ok(()),
}
}
fn refuse_unreadable_path(
schema: &CompiledSchema,
type_name: &str,
path: &[String],
security_context: Option<&SecurityContext>,
) -> Result<()> {
let mut current = type_name.to_string();
for (depth, segment) in path.iter().enumerate() {
let Some(type_def) = schema.find_type(¤t) else {
return Ok(());
};
let Some(field) = type_def
.fields
.iter()
.find(|f| crate::utils::to_snake_case(f.name.as_str()) == *segment)
else {
return Ok(());
};
refuse_unreadable_reference(schema, ¤t, field, security_context, "filter by")?;
if depth + 1 == path.len() {
return Ok(());
}
let Some(child) = field.field_type.inner_type().unwrap_or(&field.field_type).type_name()
else {
return Ok(());
};
if let Some(role) = schema.find_type(child).and_then(|t| t.requires_role.as_deref()) {
if !security_context.is_some_and(|ctx| ctx.roles.iter().any(|r| r == role)) {
return Err(FraiseQLError::Authorization {
message: format!(
"Access denied: '{current}.{}' cannot be used to filter by: it reads \
'{child}', whose read requires a role the request does not hold",
field.name
),
action: Some("read".to_string()),
resource: Some(child.to_string()),
});
}
}
current = child.to_string();
}
Ok(())
}
#[cfg(test)]
mod enrichment_backstop_tests {
#![allow(clippy::unwrap_used)]
use chrono::Utc;
use super::enforce_enrichment_resolved;
use crate::{
schema::{
CompiledSchema, InjectedParamSource, SessionVariableMapping, SessionVariableSource,
},
security::{EnrichmentMark, SecurityContext},
};
fn principal() -> SecurityContext {
SecurityContext {
user_id: crate::types::UserId::new("user-1336"),
roles: vec![],
tenant_id: None,
scopes: vec![],
attributes: std::collections::HashMap::new(),
request_id: "req-1336".to_string(),
ip_address: None,
authenticated_at: Utc::now(),
expires_at: Utc::now() + chrono::Duration::hours(1),
issuer: None,
audience: None,
email: None,
display_name: None,
}
}
fn schema_with_session_variable_consumer() -> CompiledSchema {
let mut schema = CompiledSchema::default();
schema.session_variables.variables.push(SessionVariableMapping {
name: "app.actor_id".to_string(),
source: SessionVariableSource::Enrichment {
field: "actor_id".to_string(),
},
});
schema
}
fn schema_with_inject_param_consumer() -> CompiledSchema {
let mut schema = CompiledSchema::default();
let mut query = crate::schema::QueryDefinition::new("orders", "Order");
query
.inject_params
.insert("actor".to_string(), InjectedParamSource::Enrichment("actor_id".to_string()));
schema.queries.push(query);
schema
}
#[test]
fn an_unresolved_principal_is_refused() {
for schema in [
schema_with_session_variable_consumer(),
schema_with_inject_param_consumer(),
] {
assert!(
enforce_enrichment_resolved(&schema, Some(&principal())).is_err(),
"a principal carrying no enrichment mark must not execute against a schema \
that reads enriched identity"
);
}
}
#[test]
fn a_resolved_principal_proceeds() {
let mut ctx = principal();
ctx.mark_enrichment(EnrichmentMark::Resolved);
assert!(
enforce_enrichment_resolved(&schema_with_session_variable_consumer(), Some(&ctx))
.is_ok(),
"a principal that passed a transport's seam must execute"
);
}
#[test]
fn a_system_job_is_exempt() {
let ctx = SecurityContext::system_job("ingest", "req-1", vec![], vec![], None);
assert_eq!(ctx.enrichment_mark(), Some(EnrichmentMark::Exempt));
assert!(
enforce_enrichment_resolved(&schema_with_session_variable_consumer(), Some(&ctx))
.is_ok(),
"a system job must not be refused by a gate about resolving request subjects"
);
}
#[test]
fn an_anonymous_request_is_not_this_gates_business() {
assert!(
enforce_enrichment_resolved(&schema_with_session_variable_consumer(), None).is_ok(),
"there is no subject to resolve, so there is nothing to refuse here — the \
operation's own inject/session-variable requirements still apply downstream"
);
}
#[test]
fn a_schema_that_reads_no_enriched_field_admits_an_unmarked_principal() {
assert!(
enforce_enrichment_resolved(&CompiledSchema::default(), Some(&principal())).is_ok(),
"a schema declaring no enrichment consumer must be unaffected by this guard"
);
}
}