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;