Skip to main content

fraiseql_core/security/
mutation_gate.rs

1//! Synchronous, decision-returning enforcement immediately before a mutation writes.
2//!
3//! This is the engine half of the `before:mutation` trigger. Where
4//! [`Authorizer`](crate::security::Authorizer) answers "may this principal run
5//! this operation?", a [`BeforeMutationGate`] answers the broader question a
6//! business rule asks: "given the arguments this write is about to run with, may
7//! it run at all, and are these the arguments it should run with?" The engine
8//! *enforces* the answer; the *decision* is delegated to an app-supplied trait
9//! object — in `fraiseql-server` that object runs the compiled schema's
10//! `before:mutation` function chain.
11//!
12//! # Why it lives at the chokepoint
13//!
14//! `before:mutation` is enforcement, so it is worth nothing unless it is
15//! unbypassable. It used to run once per HTTP request in the GraphQL handler,
16//! against the request's `variables`, keyed on `parse_query(…).root_field` — and
17//! that shape left three ways to execute a mutation without running its chain
18//! (#1327):
19//!
20//! 1. a **second root field**: the handler keyed on the *first* root, and since #759 the executor
21//!    runs every root serially, so the second one executed having run only the first one's chain;
22//! 2. **inline arguments**: the chain was handed `request.variables`, so `guarded(input: { … })`
23//!    was invisible to it and a rewrite could only reach variables;
24//! 3. the **REST write route**, which dispatched `after:mutation` only.
25//!
26//! The gate is therefore enforced from `execute_mutation_impl` — the single
27//! point every mutation entry path converges on, the same place `requires_role`,
28//! `requires_actor` (#966) and the `Authorizer` (#422) are enforced, and for the
29//! same reason. Per root, in document order, immediately before that root's
30//! write, with the arguments the write will actually run with.
31//!
32//! # Semantics
33//!
34//! - **Fail-closed**: any `Err` from [`BeforeMutationGate::before_mutation`] refuses the write. It
35//!   never falls through to "proceed with the original input", which would run a mutation the chain
36//!   declined to approve.
37//! - **Keyed on the field name, never the alias**: two roots calling the same mutation differ only
38//!   by response key, so keying on the alias would run one chain twice and skip the other.
39//!   [`BeforeMutationRequest::mutation`] is the name; `response_key` is carried for diagnostics
40//!   only.
41//! - **Resolved arguments**: [`BeforeMutationRequest::arguments`] is the merged view — request
42//!   variables plus inline literals, nested `$var` references already substituted — so a rule can
43//!   read what the write will use regardless of how the client spelled it.
44//! - **Rewrites reach the write**: [`BeforeMutationOutcome::ProceedWith`] replaces the arguments
45//!   the engine binds to the SQL function, not just the request variables.
46//!
47//! # Wiring
48//!
49//! Register an implementation on [`RuntimeConfig`](crate::runtime::RuntimeConfig)
50//! via
51//! [`with_before_mutation_gate`](crate::runtime::RuntimeConfig::with_before_mutation_gate),
52//! parallel to [`with_authorizer`](crate::runtime::RuntimeConfig::with_authorizer).
53
54use std::sync::Arc;
55
56use crate::{
57    error::{FraiseQLError, Result},
58    security::{GuestQueryBridge, SecurityContext},
59};
60
61/// The write a [`BeforeMutationGate`] is being asked to adjudicate.
62#[non_exhaustive]
63pub struct BeforeMutationRequest<'a> {
64    /// The authenticated principal, or `None` for an unauthenticated (anonymous) request.
65    pub principal:    Option<&'a SecurityContext>,
66    /// The mutation field name — the key the chain is looked up under. **Never** the
67    /// response alias.
68    pub mutation:     &'a str,
69    /// The key this root's result appears under in `data` (the alias when the document
70    /// supplies one, otherwise the same as `mutation`). Diagnostics only.
71    pub response_key: &'a str,
72    /// The arguments this write will run with: request variables merged with the root
73    /// field's inline literals, nested `$var` references resolved. `Null` when the write
74    /// has no arguments at all.
75    pub arguments:    &'a serde_json::Value,
76    /// The caller-scoped, read-only GraphQL bridge this hook may read through
77    /// (#1328), or `None` on a request built by hand.
78    ///
79    /// Owned rather than borrowed because the gate hands it to guest code that runs
80    /// on another thread — the Deno runtime spawns an OS thread per invocation — so
81    /// it has to outlive this call. The engine builds one per adjudicated write; a
82    /// gate that never reads simply drops it.
83    pub reader:       Option<Arc<dyn GuestQueryBridge>>,
84}
85
86impl<'a> BeforeMutationRequest<'a> {
87    /// Build a request with no read bridge. The engine is the only production
88    /// constructor; this exists so an implementor outside this crate can exercise
89    /// its own gate, which `#[non_exhaustive]` would otherwise make impossible.
90    #[must_use]
91    pub const fn new(
92        principal: Option<&'a SecurityContext>,
93        mutation: &'a str,
94        response_key: &'a str,
95        arguments: &'a serde_json::Value,
96    ) -> Self {
97        Self {
98            principal,
99            mutation,
100            response_key,
101            arguments,
102            reader: None,
103        }
104    }
105
106    /// Attach the caller-scoped read bridge (#1328).
107    #[must_use]
108    pub fn with_reader(mut self, reader: Arc<dyn GuestQueryBridge>) -> Self {
109        self.reader = Some(reader);
110        self
111    }
112}
113
114/// What the engine does next with the write.
115#[derive(Debug)]
116#[non_exhaustive]
117pub enum BeforeMutationOutcome {
118    /// Run the write with the arguments it already had.
119    Proceed,
120    /// Run the write with these arguments instead. They replace the whole argument
121    /// view the engine binds from, so an implementation that rewrites one key must
122    /// return the other keys too.
123    ProceedWith {
124        /// The argument object the write will run with.
125        arguments: serde_json::Value,
126    },
127    /// Do not run the write. `reason` is folded into a
128    /// [`FraiseQLError::Validation`] and reaches the client verbatim — it is the
129    /// rule's own message, not engine text.
130    Abort {
131        /// The client-facing reason the write was refused.
132        reason: String,
133    },
134}
135
136/// A pluggable, decision-returning enforcement point that runs immediately before
137/// a mutation writes.
138///
139/// Implementations must be `Send + Sync` to be shared across the async execution
140/// path. Implementations run on the hot path of every matching write, so a gate
141/// with nothing to say for a given mutation should return
142/// [`BeforeMutationOutcome::Proceed`] as cheaply as it can.
143///
144/// # Example
145///
146/// ```
147/// use fraiseql_core::error::Result;
148/// use fraiseql_core::security::{
149///     BeforeMutationGate, BeforeMutationOutcome, BeforeMutationRequest,
150/// };
151///
152/// /// Refuse any write whose `input.amount` exceeds a ceiling.
153/// struct CapAmount {
154///     ceiling: i64,
155/// }
156///
157/// #[async_trait::async_trait]
158/// impl BeforeMutationGate for CapAmount {
159///     async fn before_mutation(
160///         &self,
161///         req: &BeforeMutationRequest<'_>,
162///     ) -> Result<BeforeMutationOutcome> {
163///         let amount = req.arguments.pointer("/input/amount").and_then(|v| v.as_i64());
164///         match amount {
165///             Some(value) if value > self.ceiling => Ok(BeforeMutationOutcome::Abort {
166///                 reason: format!("amount {value} exceeds the {} ceiling", self.ceiling),
167///             }),
168///             _ => Ok(BeforeMutationOutcome::Proceed),
169///         }
170///     }
171/// }
172/// ```
173#[async_trait::async_trait]
174pub trait BeforeMutationGate: Send + Sync {
175    /// Decide whether this write may run, and with which arguments.
176    ///
177    /// # Errors
178    ///
179    /// Any `Err` refuses the write (fail-closed). Return
180    /// [`BeforeMutationOutcome::Abort`] for a *rule* that declined — an `Err` means
181    /// the gate itself could not reach a decision.
182    async fn before_mutation(
183        &self,
184        request: &BeforeMutationRequest<'_>,
185    ) -> Result<BeforeMutationOutcome>;
186}
187
188/// The engine-side enforcement of a configured [`BeforeMutationGate`].
189///
190/// Returns the replacement argument view when the gate rewrote it, `None` when the
191/// arguments stand (including the zero-overhead case of no gate configured).
192///
193/// Called from `execute_mutation_impl`, after every static gate and after the
194/// inline-argument merge, so `arguments` is what the write will bind from and a
195/// refusal happens before anything is written.
196///
197/// # Errors
198///
199/// Returns [`FraiseQLError::Validation`] carrying the rule's own message when the
200/// gate aborts, or the gate's own error unchanged when it could not decide.
201pub(crate) async fn enforce_before_mutation(
202    gate: Option<&dyn BeforeMutationGate>,
203    principal: Option<&SecurityContext>,
204    mutation: &str,
205    response_key: &str,
206    arguments: Option<&serde_json::Value>,
207    reader: impl FnOnce() -> Arc<dyn GuestQueryBridge>,
208) -> Result<Option<serde_json::Value>> {
209    // A write with no arguments is `Null`, not `{}`: that is the payload shape the
210    // `before:mutation` chain has always been handed for an argument-less mutation,
211    // and a rule written against it would break if it changed.
212    static NO_ARGUMENTS: serde_json::Value = serde_json::Value::Null;
213
214    let Some(gate) = gate else {
215        return Ok(None);
216    };
217
218    // Built *after* the no-gate return, so a build with no gate installed still
219    // costs one `Option` check: `reader` is a closure precisely so constructing it
220    // (an `Arc` allocation plus a `SecurityContext` clone) happens only on a write
221    // that is actually adjudicated.
222    let request = BeforeMutationRequest::new(
223        principal,
224        mutation,
225        response_key,
226        arguments.unwrap_or(&NO_ARGUMENTS),
227    )
228    .with_reader(reader());
229    match gate.before_mutation(&request).await? {
230        BeforeMutationOutcome::Proceed => Ok(None),
231        BeforeMutationOutcome::ProceedWith { arguments } => Ok(Some(arguments)),
232        BeforeMutationOutcome::Abort { reason } => Err(FraiseQLError::Validation {
233            message: reason,
234            path:    None,
235        }),
236    }
237}
238
239#[cfg(test)]
240mod tests;