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