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;