fraiseql_core/security/authorizer.rs
1//! Dynamic, decision-returning operation-level authorization.
2//!
3//! Where [`requires_role`](crate::schema::QueryDefinition) answers the *static*
4//! question "does this principal hold role X?", an [`Authorizer`] answers the
5//! *dynamic* question "may **this** principal run **this** operation, given its
6//! input?". It is the operation-level analogue of the
7//! [`FieldAuthorizer`](crate::security::FieldAuthorizer) and the counterpart of the
8//! [`RLSPolicy`](crate::security::RLSPolicy) plugin: a Policy Enforcement Point
9//! where the engine *enforces* but the *decision* is delegated to an app-supplied
10//! trait object (in-process rules, a DB query, or an external service).
11//!
12//! # Semantics
13//!
14//! - **Fail-closed**: any `Err` returned by [`Authorizer::authorize`] is treated as a hard deny —
15//! the request fails with [`FraiseQLError::Authorization`] (HTTP 403 / `FORBIDDEN`). The
16//! underlying error is *not* surfaced to the client (no information leak).
17//! - **Anonymous requests**: [`AuthzRequest::principal`] is `None` on the unauthenticated entry
18//! path. The authorizer is still consulted, so an app may explicitly allow public operations or
19//! deny everything anonymous — the decision is the app's, not the engine's.
20//! - **AND-composition**: the decision composes with the static `requires_role` gate as a logical
21//! AND — an operation runs only if *both* the static gate and the authorizer allow it. The
22//! `requires_role` gate keeps its enumeration-hiding "not found in schema" response; the
23//! authorizer denies with an explicit 403.
24//! - **Every level, by default**: a read that reaches a nested type — a GraphQL selection `users {
25//! orders { … } }`, a REST embed `?select=orders(…)` — puts that level to the authorizer too,
26//! once per request per path (never per row), with [`AuthzRequest::nesting`] set. The rule the
27//! developer wrote for reading `Order` holds wherever `Order` is read. An authorizer that means
28//! to gate operations only allows every request whose `nesting` is `Some`.
29//!
30//! # Matching a type or a name
31//!
32//! [`AuthzRequest::target_type`] is the type a request reads (or writes), on every call:
33//! root and nested, GraphQL and REST. Match on it to hold every read of `Order`, whatever
34//! the transport or the entry point. [`AuthzRequest::name`] is the root field at the
35//! root. At a nested level it is the type's **canonical list query**: its first declared
36//! SQL-backed list query, the spelling REST has always passed, or the type name when it
37//! has none. A rule on `name` therefore catches a nested read only through that one query.
38//!
39//! # Wiring
40//!
41//! Register an implementation on [`RuntimeConfig`](crate::runtime::RuntimeConfig) via
42//! [`with_authorizer`](crate::runtime::RuntimeConfig::with_authorizer), exactly parallel to
43//! [`with_field_authorizer`](crate::runtime::RuntimeConfig::with_field_authorizer) and
44//! [`with_rls_policy`](crate::runtime::RuntimeConfig::with_rls_policy).
45
46use crate::{
47 error::{FraiseQLError, Result},
48 security::SecurityContext,
49};
50
51/// The kind of GraphQL operation being authorized.
52#[non_exhaustive]
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum OperationKind {
55 /// A read operation (regular query, aggregate, window, node lookup, federation
56 /// entity resolution, or introspection).
57 Query,
58 /// A write operation (GraphQL mutation, or a REST write mapped to one).
59 Mutation,
60 /// A subscription operation (authorized once at establishment).
61 Subscription,
62}
63
64impl OperationKind {
65 /// A lowercase, stable string label for this kind (used in the deny error's `action`).
66 #[must_use]
67 pub const fn as_str(self) -> &'static str {
68 match self {
69 OperationKind::Query => "query",
70 OperationKind::Mutation => "mutation",
71 OperationKind::Subscription => "subscription",
72 }
73 }
74}
75
76/// Where a nested level sits: the type it is reached through, and the path to it.
77#[non_exhaustive]
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct AuthzNesting {
80 /// The type whose field reaches this level (`"User"` for `users { orders }`).
81 pub parent_type: String,
82 /// The field names from the root field to this level, dot-separated: `"orders"` for
83 /// `users { orders }`, `"orders.items"` one level further. REST names relationships.
84 pub path: String,
85}
86
87impl AuthzNesting {
88 /// A nested level reached through `parent_type` at `path`.
89 #[must_use]
90 pub fn new(parent_type: impl Into<String>, path: impl Into<String>) -> Self {
91 Self {
92 parent_type: parent_type.into(),
93 path: path.into(),
94 }
95 }
96}
97
98/// One operation or level to put to the [`Authorizer`] — what [`enforce_authz`] turns
99/// into an [`AuthzRequest`].
100#[non_exhaustive]
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct AuthzOperation {
103 /// [`AuthzRequest::operation`].
104 pub kind: OperationKind,
105 /// [`AuthzRequest::name`].
106 pub name: String,
107 /// [`AuthzRequest::target_type`].
108 pub target_type: Option<String>,
109 /// [`AuthzRequest::nesting`].
110 pub nesting: Option<AuthzNesting>,
111}
112
113impl AuthzOperation {
114 /// A root operation: `name` is the root field, `target_type` the type it returns
115 /// (`None` where it names no single type — introspection, `node`, `_entities`).
116 #[must_use]
117 pub fn root(kind: OperationKind, name: impl Into<String>, target_type: Option<&str>) -> Self {
118 Self {
119 kind,
120 name: name.into(),
121 target_type: target_type.map(str::to_string),
122 nesting: None,
123 }
124 }
125
126 /// A nested read of `target_type`, named by its canonical list query (or the type).
127 #[must_use]
128 pub fn nested(name: impl Into<String>, target_type: &str, nesting: AuthzNesting) -> Self {
129 Self {
130 kind: OperationKind::Query,
131 name: name.into(),
132 target_type: Some(target_type.to_string()),
133 nesting: Some(nesting),
134 }
135 }
136}
137
138/// An operation-level authorization request handed to an [`Authorizer`].
139///
140/// Carries the principal (or `None` for an anonymous request), the operation kind
141/// and root field name, the type read, where a nested level sits, and the request
142/// input — the inputs a static role check lacks.
143#[non_exhaustive]
144pub struct AuthzRequest<'a> {
145 /// The authenticated principal, or `None` for an unauthenticated (anonymous) request.
146 pub principal: Option<&'a SecurityContext>,
147 /// The kind of operation (query / mutation / subscription).
148 pub operation: OperationKind,
149 /// The root operation field name (e.g. `"users"`, `"createUser"`, `"_entities"`,
150 /// `"__schema"`).
151 pub name: &'a str,
152 /// The request input — GraphQL variables or REST arguments — when present.
153 pub input: Option<&'a serde_json::Value>,
154 /// The type this request reads or writes: the root field's return type at the root, the
155 /// level's type at a nested level. `None` only where the root names no single type
156 /// (introspection, `node` and `_entities` before their type is known — each is asked
157 /// again with it once it is).
158 pub target_type: Option<&'a str>,
159 /// `None` at the root; where a nested level sits otherwise.
160 pub nesting: Option<&'a AuthzNesting>,
161}
162
163/// The decision an [`Authorizer`] returns for a single operation.
164#[non_exhaustive]
165pub enum AuthzDecision {
166 /// Allow the operation to execute.
167 Allow,
168 /// Deny the operation. The `reason` is folded into the
169 /// [`FraiseQLError::Authorization`] message (HTTP 403 / `FORBIDDEN`).
170 Deny {
171 /// A domain-specific, client-facing denial reason (e.g. `"insufficient tier"`).
172 reason: String,
173 },
174}
175
176/// A pluggable, decision-returning operation-level authorizer.
177///
178/// Implementations decide, per principal / per operation / per input, whether an
179/// operation may execute. The engine enforces the decision; this trait supplies it.
180/// Implementations must be `Send + Sync` to be shared across the async execution path.
181///
182/// # Example
183///
184/// ```
185/// use fraiseql_core::security::{Authorizer, AuthzRequest, AuthzDecision, OperationKind};
186/// use fraiseql_core::error::Result;
187///
188/// /// Allow reads for everyone; require an authenticated principal for writes.
189/// struct WritesNeedAuth;
190///
191/// impl Authorizer for WritesNeedAuth {
192/// fn authorize(&self, req: &AuthzRequest<'_>) -> Result<AuthzDecision> {
193/// // Reads are public; writes (and any future operation kind) need a principal.
194/// // `OperationKind` is `#[non_exhaustive]`, so avoid an exhaustive match here.
195/// if matches!(req.operation, OperationKind::Query) || req.principal.is_some() {
196/// Ok(AuthzDecision::Allow)
197/// } else {
198/// Ok(AuthzDecision::Deny { reason: "authentication required".to_string() })
199/// }
200/// }
201/// }
202/// ```
203pub trait Authorizer: Send + Sync {
204 /// Decide whether the principal may run the requested operation.
205 ///
206 /// # Errors
207 ///
208 /// Any `Err` is treated as a **hard deny** (fail-closed): the request fails with
209 /// [`FraiseQLError::Authorization`] (HTTP 403 / `FORBIDDEN`) and the underlying
210 /// error is not surfaced to the client. Return [`AuthzDecision::Deny`] for an
211 /// ordinary, expected denial; reserve `Err` for policy-evaluation failures (e.g.
212 /// an unreachable policy backend).
213 fn authorize(&self, req: &AuthzRequest<'_>) -> Result<AuthzDecision>;
214}
215
216/// The fail-closed deny error: a generic 403 that never echoes the underlying policy
217/// error (avoids leaking why, beyond the app-supplied `reason`).
218fn authz_deny_error(op: &AuthzOperation, reason: &str) -> FraiseQLError {
219 let message = match (&op.nesting, &op.target_type) {
220 (Some(nesting), Some(target)) => format!(
221 "Read of '{target}' at '{}.{}' denied: {reason}",
222 nesting.parent_type, nesting.path
223 ),
224 _ => format!("Operation '{}' denied: {reason}", op.name),
225 };
226 FraiseQLError::Authorization {
227 message,
228 action: Some(op.kind.as_str().to_string()),
229 resource: Some(op.name.clone()),
230 }
231}
232
233/// The answer when a policy backend fails to decide: fail closed, and say so honestly.
234///
235/// A policy `Err` is not a refusal: nobody adjudicated the caller, the backend could not
236/// be reached. Reporting it as 403 told operators an outage was a permissions problem
237/// and told clients not to retry exactly when they should (#1374). It is a 503 with no
238/// detail — the policy's own error is never surfaced (no information leak) — and the
239/// operation never executes, exactly as for a deny. Shared by the operation and the
240/// field authorizer, so the transports cannot disagree.
241#[must_use]
242pub fn policy_unavailable() -> FraiseQLError {
243 FraiseQLError::ServiceUnavailable {
244 message: "authorization policy unavailable".to_string(),
245 retry_after: None,
246 }
247}
248
249/// Run the configured [`Authorizer`] over one or more operations or levels, fail-closed.
250///
251/// A multi-root query yields one call per root; a nested level, one call of its own. Any
252/// [`AuthzDecision::Deny`] returns [`FraiseQLError::Authorization`] (403), with the `Deny`'s
253/// `reason` folded into the message. A policy `Err` returns [`policy_unavailable`] (503,
254/// the error itself not surfaced). Either way the operation never executes.
255///
256/// This is the canonical enforcement entry point. It is `pub` so transports that do
257/// not route through the core executor (e.g. the `WebSocket` subscription handler in
258/// `fraiseql-server`) can enforce the same fail-closed contract without reconstructing
259/// the (`#[non_exhaustive]`) [`AuthzRequest`] themselves.
260///
261/// # Errors
262///
263/// Returns [`FraiseQLError::Authorization`] on the first `Deny` decision, and
264/// [`FraiseQLError::ServiceUnavailable`] on the first policy error.
265pub fn enforce_authz(
266 authorizer: &dyn Authorizer,
267 principal: Option<&SecurityContext>,
268 operations: &[AuthzOperation],
269 input: Option<&serde_json::Value>,
270) -> Result<()> {
271 for op in operations {
272 let req = AuthzRequest {
273 principal,
274 operation: op.kind,
275 name: &op.name,
276 input,
277 target_type: op.target_type.as_deref(),
278 nesting: op.nesting.as_ref(),
279 };
280 match authorizer.authorize(&req) {
281 Ok(AuthzDecision::Allow) => {},
282 Ok(AuthzDecision::Deny { reason }) => return Err(authz_deny_error(op, &reason)),
283 Err(_) => return Err(policy_unavailable()),
284 }
285 }
286 Ok(())
287}
288
289#[cfg(test)]
290mod tests;