fraiseql_core/security/field_authorizer.rs
1//! Dynamic, decision-returning field-level authorization.
2//!
3//! Where [`FieldFilter`](crate::security::FieldFilter) /
4//! [`requires_scope`](crate::schema::FieldDefinition) answer the *static* question
5//! "does this principal hold scope X?", a [`FieldAuthorizer`] answers the *dynamic*
6//! question "may **this** principal read **this** field of **this** row, given the
7//! field's arguments?". It is the field-level analogue of an operation-level
8//! authorizer and the counterpart of the `RLSPolicy` plugin: a Policy Enforcement
9//! Point where the engine *enforces* but the *decision* is delegated to an
10//! app-supplied trait object.
11//!
12//! # Semantics
13//!
14//! - **Fail-closed**: any `Err` returned by [`FieldAuthorizer::authorize_field`] is treated as a
15//! hard deny — the request fails with [`FraiseQLError::Authorization`] (HTTP 403 / `FORBIDDEN`).
16//! The field value is never served on a policy failure.
17//! - **AND-composition**: the dynamic decision composes with the static `requires_scope` gate as a
18//! logical AND — a field is visible only if *both* the static gate and the dynamic authorizer
19//! allow it.
20//! - **Deny policy**: a [`FieldAuthzDecision::Deny`] reuses the existing [`FieldDenyPolicy`]:
21//! `Reject` fails the whole query, `Mask` nulls just that field on just that row.
22//!
23//! Only fields marked policy-gated in the compiled schema
24//! ([`FieldDefinition::authorize`](crate::schema::FieldDefinition)) are passed to the
25//! authorizer, so non-gated fields incur zero per-row overhead.
26//!
27//! # Wiring
28//!
29//! Register an implementation on [`RuntimeConfig`](crate::runtime::RuntimeConfig) via
30//! [`with_field_authorizer`](crate::runtime::RuntimeConfig::with_field_authorizer),
31//! exactly parallel to [`with_rls_policy`](crate::runtime::RuntimeConfig::with_rls_policy).
32
33use serde_json::Value as JsonValue;
34
35use crate::{
36 db::types::JsonbValue,
37 error::{FraiseQLError, Result},
38 graphql::FieldSelection,
39 runtime::projection::effective_selections,
40 schema::{CompiledSchema, FieldDenyPolicy, FieldType},
41 security::SecurityContext,
42};
43
44/// A field-level authorization request handed to a [`FieldAuthorizer`].
45///
46/// Carries the principal, the field being resolved (its GraphQL type and name), the
47/// full parent row it is being projected from, and the field's GraphQL arguments —
48/// the exact inputs a static scope check lacks.
49#[non_exhaustive]
50pub struct FieldAuthzRequest<'a> {
51 /// The authenticated principal making the request.
52 pub principal: &'a SecurityContext,
53 /// The GraphQL type name that owns the field (e.g. `"User"`).
54 pub type_name: &'a str,
55 /// The field name being authorized (e.g. `"email"`).
56 pub field_name: &'a str,
57 /// The full row/object the field is projected from, when available.
58 ///
59 /// This is the *complete* fetched row (not just the selected fields), so a
60 /// policy may key on columns the client did not select (e.g. an `owner_id`
61 /// used to decide ownership). `None` only on paths where no row context exists.
62 pub parent: Option<&'a serde_json::Value>,
63 /// The field's GraphQL arguments, when present.
64 pub arguments: Option<&'a serde_json::Value>,
65}
66
67/// The decision a [`FieldAuthorizer`] returns for a single field on a single row.
68#[non_exhaustive]
69pub enum FieldAuthzDecision {
70 /// Allow the field to be resolved and projected.
71 Allow,
72 /// Deny access to the field.
73 ///
74 /// `code` is a domain-specific deny code (folded into the `Authorization`
75 /// error message on a `Reject`). `on_deny` reuses [`FieldDenyPolicy`]:
76 /// - [`FieldDenyPolicy::Reject`] fails the whole query with 403 `FORBIDDEN`,
77 /// - [`FieldDenyPolicy::Mask`] succeeds but returns `null` for this field on this row.
78 Deny {
79 /// Domain-specific deny code (e.g. `"not_owner"`).
80 code: String,
81 /// What to do on deny — reject the query or mask the field.
82 on_deny: FieldDenyPolicy,
83 },
84}
85
86/// A pluggable, decision-returning field-level authorizer.
87///
88/// Implementations decide, per principal / per row / per field-arguments, whether a
89/// policy-gated field may be read. The engine enforces the decision; this trait
90/// supplies it. Implementations must be `Send + Sync` to be shared across the async
91/// execution path.
92///
93/// # Example
94///
95/// ```
96/// use fraiseql_core::security::{
97/// FieldAuthorizer, FieldAuthzRequest, FieldAuthzDecision,
98/// };
99/// use fraiseql_core::schema::FieldDenyPolicy;
100/// use fraiseql_core::error::Result;
101///
102/// /// Reveal a gated field only to the row's owner; mask it for everyone else.
103/// struct OwnerOnly;
104///
105/// impl FieldAuthorizer for OwnerOnly {
106/// fn authorize_field(&self, req: &FieldAuthzRequest<'_>) -> Result<FieldAuthzDecision> {
107/// let owner = req
108/// .parent
109/// .and_then(|p| p.get("owner_id"))
110/// .and_then(|v| v.as_str());
111/// if owner == Some(req.principal.user_id.as_str()) {
112/// Ok(FieldAuthzDecision::Allow)
113/// } else {
114/// Ok(FieldAuthzDecision::Deny {
115/// code: "not_owner".to_string(),
116/// on_deny: FieldDenyPolicy::Mask,
117/// })
118/// }
119/// }
120/// }
121/// ```
122pub trait FieldAuthorizer: Send + Sync {
123 /// Decide whether the principal may read the requested field on this row.
124 ///
125 /// # Errors
126 ///
127 /// Any `Err` is treated as a **hard deny** (fail-closed): the request fails
128 /// with [`FraiseQLError::Authorization`]
129 /// (HTTP 403 / `FORBIDDEN`) and the field value is never served. Return
130 /// [`FieldAuthzDecision::Deny`] for an ordinary, expected denial; reserve `Err`
131 /// for policy-evaluation failures (e.g. an unreachable policy backend).
132 fn authorize_field(&self, req: &FieldAuthzRequest<'_>) -> Result<FieldAuthzDecision>;
133}
134
135// ============================================================================
136// Runtime enforcement helpers (shared by the executor's projection paths)
137// ============================================================================
138
139/// A selected, policy-gated field on an entity row, with the data the authorizer
140/// needs. `name` is the GraphQL field name — also the projected output key for the
141/// entity row (consistent with the static-gate masking in
142/// [`null_masked_fields`](crate::runtime::executor)).
143pub(crate) struct GatedField {
144 /// GraphQL field name — used for the authorizer request and the static-mask check.
145 pub(crate) field_name: String,
146 /// Response key (alias) when the selection aliased the field. The projected key is
147 /// the field name on the query path (no alias applied) or the response key on the
148 /// mutation path; enforcement tries both so masking is correct on either path.
149 pub(crate) alias: Option<String>,
150 /// The field's GraphQL arguments as a JSON object, when any are present.
151 pub(crate) arguments: Option<JsonValue>,
152}
153
154/// Resolve the object type a (possibly list-wrapped) field points to, if any.
155fn object_type_of(field_type: &FieldType) -> Option<&str> {
156 field_type
157 .type_name()
158 .or_else(|| field_type.inner_type().and_then(FieldType::type_name))
159}
160
161/// Build a JSON object of a selection's GraphQL arguments, or `None` if it has none.
162fn field_arguments_json(sel: &FieldSelection) -> Option<JsonValue> {
163 if sel.arguments.is_empty() {
164 return None;
165 }
166 let mut map = serde_json::Map::with_capacity(sel.arguments.len());
167 for arg in &sel.arguments {
168 let value = serde_json::from_str::<JsonValue>(&arg.value_json)
169 .unwrap_or_else(|_| JsonValue::String(arg.value_json.clone()));
170 map.insert(arg.name.clone(), value);
171 }
172 Some(JsonValue::Object(map))
173}
174
175/// Returns `true` if any field in `fields` (selected on `type_name`, top-level **or**
176/// nested) resolves to a policy-gated [`FieldDefinition`](crate::schema::FieldDefinition).
177///
178/// Used pre-plan to decide whether to bypass the response cache and the SQL
179/// projection hint (the per-row decision is neither cacheable nor compatible with a
180/// selection-stripped row).
181pub(crate) fn selection_set_selects_gated_field(
182 schema: &CompiledSchema,
183 type_name: &str,
184 fields: &[FieldSelection],
185) -> bool {
186 let Some(type_def) = schema.find_type(type_name) else {
187 return false;
188 };
189 // Resolve inline `... on T` fragments so a gated field selected through a
190 // fragment (interface/union member, e.g. a cascade `entity { ... on Post {
191 // gated } }` or a union mutation's `... on Post { gated }`) is not invisible.
192 effective_selections(fields, type_name, schema).iter().any(|sel| {
193 type_def.fields.iter().any(|f| f.name.as_str() == sel.name && f.authorize)
194 || selection_field_has_gated_descendant(schema, type_name, sel)
195 })
196}
197
198/// Returns `true` if `sel` (a field on `parent_type`) has a policy-gated field
199/// somewhere in its sub-selection (depth ≥ 1).
200fn selection_field_has_gated_descendant(
201 schema: &CompiledSchema,
202 parent_type: &str,
203 sel: &FieldSelection,
204) -> bool {
205 if sel.nested_fields.is_empty() {
206 return false;
207 }
208 let Some(parent_def) = schema.find_type(parent_type) else {
209 return false;
210 };
211 let Some(field_def) = parent_def.fields.iter().find(|f| f.name.as_str() == sel.name) else {
212 return false;
213 };
214 let Some(child_type) = object_type_of(&field_def.field_type) else {
215 return false;
216 };
217 let Some(child_def) = schema.find_type(child_type) else {
218 return false;
219 };
220 effective_selections(&sel.nested_fields, child_type, schema)
221 .iter()
222 .any(|child_sel| {
223 child_def
224 .fields
225 .iter()
226 .any(|f| f.name.as_str() == child_sel.name && f.authorize)
227 || selection_field_has_gated_descendant(schema, child_type, child_sel)
228 })
229}
230
231/// Returns `true` if a policy-gated field is selected **below** the given fields
232/// (nested inside an object sub-selection). The current enforcement covers only the
233/// top-level entity row; the caller fail-closes when this is `true`.
234pub(crate) fn selection_set_has_nested_gated_field(
235 schema: &CompiledSchema,
236 type_name: &str,
237 fields: &[FieldSelection],
238) -> bool {
239 effective_selections(fields, type_name, schema)
240 .iter()
241 .any(|sel| selection_field_has_gated_descendant(schema, type_name, sel))
242}
243
244/// Collect the top-level policy-gated fields selected on `type_name`, paired with
245/// their alias and GraphQL arguments.
246pub(crate) fn collect_top_level_gated_fields(
247 schema: &CompiledSchema,
248 type_name: &str,
249 fields: &[FieldSelection],
250) -> Vec<GatedField> {
251 let Some(type_def) = schema.find_type(type_name) else {
252 return Vec::new();
253 };
254 effective_selections(fields, type_name, schema)
255 .into_iter()
256 .filter(|sel| type_def.fields.iter().any(|f| f.name.as_str() == sel.name && f.authorize))
257 .map(|sel| GatedField {
258 field_name: sel.name.clone(),
259 alias: sel.alias.clone(),
260 arguments: field_arguments_json(sel),
261 })
262 .collect()
263}
264
265/// Fail-closed guard for a projection path that does not run the field authorizer.
266///
267/// Returns [`FraiseQLError::Authorization`] (403) when a policy-gated field is
268/// selected on `type_name` (top-level or nested). `path` names the unsupported path
269/// in the error message. Used by the anonymous-query and REST projection paths in
270/// the current version; full per-row enforcement on those paths is a tracked
271/// follow-up.
272///
273/// # Errors
274///
275/// Returns [`FraiseQLError::Authorization`] if a gated field is selected.
276pub(crate) fn deny_if_gated_field_selected(
277 schema: &CompiledSchema,
278 type_name: &str,
279 fields: &[FieldSelection],
280 path: &str,
281) -> Result<()> {
282 if selection_set_selects_gated_field(schema, type_name, fields) {
283 return Err(FraiseQLError::Authorization {
284 message: format!(
285 "Field-level authorization is not enforced on the {path} path; a policy-gated \
286 field on type '{type_name}' was selected"
287 ),
288 action: Some("read".to_string()),
289 resource: Some(type_name.to_string()),
290 });
291 }
292 Ok(())
293}
294
295/// Fail-closed guard for a path whose result type is dynamic/unknown at the call site
296/// (Relay `node`, federation `_entities`). Returns [`FraiseQLError::Authorization`]
297/// (403) when the schema declares **any** policy-gated field, since the path cannot
298/// yet enforce the dynamic decision. Conservative by design (#423).
299///
300/// # Errors
301///
302/// Returns [`FraiseQLError::Authorization`] if the schema has any gated field.
303#[cfg(feature = "federation")]
304pub(crate) fn deny_if_schema_has_gated_field(schema: &CompiledSchema, path: &str) -> Result<()> {
305 if schema.has_any_authorize_field() {
306 return Err(FraiseQLError::Authorization {
307 message: format!(
308 "Field-level authorization is not enforced on the {path} path, but the schema \
309 declares policy-gated fields"
310 ),
311 action: Some("read".to_string()),
312 resource: None,
313 });
314 }
315 Ok(())
316}
317
318/// The fail-closed deny error: a generic 403 that never echoes the parent row or the
319/// underlying policy error (avoids leaking why access was denied).
320fn field_authz_error(type_name: &str, field: &str, code: &str) -> FraiseQLError {
321 FraiseQLError::Authorization {
322 message: format!("Access denied to field '{field}' on type '{type_name}' [{code}]"),
323 action: Some("read".to_string()),
324 resource: Some(format!("{type_name}.{field}")),
325 }
326}
327
328/// Apply the configured [`FieldAuthorizer`] to the projected result of a regular
329/// query, per row. `results` are the **full** fetched rows (the `parent` context);
330/// `projected` is the response value, mutated in place (a `Mask` decision nulls the
331/// field on that row). `statically_masked` are fields the static `requires_scope`
332/// gate already denied — skipped here (AND-composition: already denied).
333///
334/// Fail-closed: a `Reject` decision or any policy `Err` returns
335/// [`FraiseQLError::Authorization`] (403) and the value is never served.
336///
337/// # Errors
338///
339/// Returns [`FraiseQLError::Authorization`] on any `Reject` decision or policy error.
340pub(crate) fn apply_field_authorizer(
341 pass: &FieldAuthzPass<'_>,
342 results: &[JsonbValue],
343 projected: &mut JsonValue,
344 returns_list: bool,
345) -> Result<()> {
346 if pass.gated.is_empty() {
347 return Ok(());
348 }
349 match projected {
350 JsonValue::Array(rows) if returns_list => {
351 for (i, row) in rows.iter_mut().enumerate() {
352 let parent = results.get(i).map(JsonbValue::as_value);
353 enforce_row(pass, parent, row)?;
354 }
355 },
356 JsonValue::Object(_) if !returns_list => {
357 let parent = results.first().map(JsonbValue::as_value);
358 enforce_row(pass, parent, projected)?;
359 },
360 _ => {},
361 }
362 Ok(())
363}
364
365/// Apply the configured [`FieldAuthorizer`] to a single already-projected entity
366/// object (the mutation path), using `parent` as the full entity for the decision.
367/// `projected` is mutated in place (a `Mask` decision nulls the field).
368///
369/// Fail-closed: a `Reject` decision or any policy `Err` returns
370/// [`FraiseQLError::Authorization`] (403).
371///
372/// # Errors
373///
374/// Returns [`FraiseQLError::Authorization`] on any `Reject` decision or policy error.
375pub(crate) fn apply_field_authorizer_to_entity(
376 pass: &FieldAuthzPass<'_>,
377 parent: &JsonValue,
378 projected: &mut JsonValue,
379) -> Result<()> {
380 if pass.gated.is_empty() {
381 return Ok(());
382 }
383 enforce_row(pass, Some(parent), projected)
384}
385
386/// The per-query context of a field-authorization pass: who is asking, on which type,
387/// which fields are gated, and which the static gate already denied. Grouped so the
388/// per-row enforcement carries one context instead of many parameters.
389pub(crate) struct FieldAuthzPass<'a> {
390 /// The configured authorizer.
391 pub(crate) authorizer: &'a dyn FieldAuthorizer,
392 /// The authenticated principal.
393 pub(crate) principal: &'a SecurityContext,
394 /// The GraphQL type that owns the rows.
395 pub(crate) type_name: &'a str,
396 /// The selected, policy-gated fields to enforce.
397 pub(crate) gated: &'a [GatedField],
398 /// Fields the static `requires_scope` gate already denied — skipped (AND-composition).
399 pub(crate) statically_masked: &'a [String],
400}
401
402/// Enforce the gated fields on a single projected row object.
403fn enforce_row(
404 pass: &FieldAuthzPass<'_>,
405 parent: Option<&JsonValue>,
406 projected_row: &mut JsonValue,
407) -> Result<()> {
408 let JsonValue::Object(map) = projected_row else {
409 return Ok(());
410 };
411 let FieldAuthzPass {
412 authorizer,
413 principal,
414 type_name,
415 gated,
416 statically_masked,
417 } = *pass;
418 for gf in gated {
419 // Resolve the projected key: the field name (query path) or the alias
420 // (mutation path applies response keys). Absent → not in this row, nothing to gate.
421 let projected_key = if map.contains_key(&gf.field_name) {
422 gf.field_name.clone()
423 } else if let Some(alias) = gf.alias.as_ref().filter(|a| map.contains_key(a.as_str())) {
424 alias.clone()
425 } else {
426 continue;
427 };
428 // AND-composition: the static gate already masked this field → already denied.
429 if statically_masked.iter().any(|m| m == &gf.field_name) {
430 continue;
431 }
432 let req = FieldAuthzRequest {
433 principal,
434 type_name,
435 field_name: &gf.field_name,
436 parent,
437 arguments: gf.arguments.as_ref(),
438 };
439 match authorizer.authorize_field(&req) {
440 Ok(FieldAuthzDecision::Allow) => {},
441 Ok(FieldAuthzDecision::Deny {
442 on_deny: FieldDenyPolicy::Mask,
443 ..
444 }) => {
445 map.insert(projected_key, JsonValue::Null);
446 },
447 Ok(FieldAuthzDecision::Deny {
448 code,
449 on_deny: FieldDenyPolicy::Reject,
450 }) => {
451 return Err(field_authz_error(type_name, &gf.field_name, &code));
452 },
453 Err(_) => {
454 // Fail-closed: any policy error is a hard deny. The underlying error is
455 // not surfaced to the client (no information leak).
456 return Err(field_authz_error(
457 type_name,
458 &gf.field_name,
459 "field_authorization_failed",
460 ));
461 },
462 }
463 }
464 Ok(())
465}
466
467#[cfg(test)]
468mod tests;