Skip to main content

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;