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//!
25//! # Wiring
26//!
27//! Register an implementation on [`RuntimeConfig`](crate::runtime::RuntimeConfig) via
28//! [`with_authorizer`](crate::runtime::RuntimeConfig::with_authorizer), exactly parallel to
29//! [`with_field_authorizer`](crate::runtime::RuntimeConfig::with_field_authorizer) and
30//! [`with_rls_policy`](crate::runtime::RuntimeConfig::with_rls_policy).
31
32use crate::{
33 error::{FraiseQLError, Result},
34 security::SecurityContext,
35};
36
37/// The kind of GraphQL operation being authorized.
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum OperationKind {
41 /// A read operation (regular query, aggregate, window, node lookup, federation
42 /// entity resolution, or introspection).
43 Query,
44 /// A write operation (GraphQL mutation, or a REST write mapped to one).
45 Mutation,
46 /// A subscription operation (authorized once at establishment).
47 Subscription,
48}
49
50impl OperationKind {
51 /// A lowercase, stable string label for this kind (used in the deny error's `action`).
52 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 OperationKind::Query => "query",
56 OperationKind::Mutation => "mutation",
57 OperationKind::Subscription => "subscription",
58 }
59 }
60}
61
62/// An operation-level authorization request handed to an [`Authorizer`].
63///
64/// Carries the principal (or `None` for an anonymous request), the operation kind
65/// and root field name, and the request input — the inputs a static role check lacks.
66#[non_exhaustive]
67pub struct AuthzRequest<'a> {
68 /// The authenticated principal, or `None` for an unauthenticated (anonymous) request.
69 pub principal: Option<&'a SecurityContext>,
70 /// The kind of operation (query / mutation / subscription).
71 pub operation: OperationKind,
72 /// The root operation field name (e.g. `"users"`, `"createUser"`, `"_entities"`,
73 /// `"__schema"`).
74 pub name: &'a str,
75 /// The request input — GraphQL variables or REST arguments — when present.
76 pub input: Option<&'a serde_json::Value>,
77}
78
79/// The decision an [`Authorizer`] returns for a single operation.
80#[non_exhaustive]
81pub enum AuthzDecision {
82 /// Allow the operation to execute.
83 Allow,
84 /// Deny the operation. The `reason` is folded into the
85 /// [`FraiseQLError::Authorization`] message (HTTP 403 / `FORBIDDEN`).
86 Deny {
87 /// A domain-specific, client-facing denial reason (e.g. `"insufficient tier"`).
88 reason: String,
89 },
90}
91
92/// A pluggable, decision-returning operation-level authorizer.
93///
94/// Implementations decide, per principal / per operation / per input, whether an
95/// operation may execute. The engine enforces the decision; this trait supplies it.
96/// Implementations must be `Send + Sync` to be shared across the async execution path.
97///
98/// # Example
99///
100/// ```
101/// use fraiseql_core::security::{Authorizer, AuthzRequest, AuthzDecision, OperationKind};
102/// use fraiseql_core::error::Result;
103///
104/// /// Allow reads for everyone; require an authenticated principal for writes.
105/// struct WritesNeedAuth;
106///
107/// impl Authorizer for WritesNeedAuth {
108/// fn authorize(&self, req: &AuthzRequest<'_>) -> Result<AuthzDecision> {
109/// // Reads are public; writes (and any future operation kind) need a principal.
110/// // `OperationKind` is `#[non_exhaustive]`, so avoid an exhaustive match here.
111/// if matches!(req.operation, OperationKind::Query) || req.principal.is_some() {
112/// Ok(AuthzDecision::Allow)
113/// } else {
114/// Ok(AuthzDecision::Deny { reason: "authentication required".to_string() })
115/// }
116/// }
117/// }
118/// ```
119pub trait Authorizer: Send + Sync {
120 /// Decide whether the principal may run the requested operation.
121 ///
122 /// # Errors
123 ///
124 /// Any `Err` is treated as a **hard deny** (fail-closed): the request fails with
125 /// [`FraiseQLError::Authorization`] (HTTP 403 / `FORBIDDEN`) and the underlying
126 /// error is not surfaced to the client. Return [`AuthzDecision::Deny`] for an
127 /// ordinary, expected denial; reserve `Err` for policy-evaluation failures (e.g.
128 /// an unreachable policy backend).
129 fn authorize(&self, req: &AuthzRequest<'_>) -> Result<AuthzDecision>;
130}
131
132/// The fail-closed deny error: a generic 403 that never echoes the underlying policy
133/// error (avoids leaking why, beyond the app-supplied `reason`).
134fn authz_deny_error(op: OperationKind, name: &str, reason: &str) -> FraiseQLError {
135 FraiseQLError::Authorization {
136 message: format!("Operation '{name}' denied: {reason}"),
137 action: Some(op.as_str().to_string()),
138 resource: Some(name.to_string()),
139 }
140}
141
142/// Run the configured [`Authorizer`] over one or more root operations, fail-closed.
143///
144/// A multi-root query yields one call per root. Any [`AuthzDecision::Deny`] or any
145/// `Err` returns [`FraiseQLError::Authorization`] (403) and the operation never
146/// executes. A `Deny`'s `reason` is folded into the message; a policy `Err` is not
147/// surfaced (no information leak).
148///
149/// This is the canonical enforcement entry point. It is `pub` so transports that do
150/// not route through the core executor (e.g. the `WebSocket` subscription handler in
151/// `fraiseql-server`) can enforce the same fail-closed contract without reconstructing
152/// the (`#[non_exhaustive]`) [`AuthzRequest`] themselves.
153///
154/// # Errors
155///
156/// Returns [`FraiseQLError::Authorization`] on the first `Deny` decision or policy error.
157pub fn enforce_authz(
158 authorizer: &dyn Authorizer,
159 principal: Option<&SecurityContext>,
160 operations: &[(OperationKind, String)],
161 input: Option<&serde_json::Value>,
162) -> Result<()> {
163 for (op, name) in operations {
164 let req = AuthzRequest {
165 principal,
166 operation: *op,
167 name,
168 input,
169 };
170 match authorizer.authorize(&req) {
171 Ok(AuthzDecision::Allow) => {},
172 Ok(AuthzDecision::Deny { reason }) => return Err(authz_deny_error(*op, name, &reason)),
173 Err(_) => {
174 // Fail-closed: any policy error is a hard deny. The underlying error is
175 // not surfaced to the client (no information leak).
176 return Err(authz_deny_error(*op, name, "authorization failed"));
177 },
178 }
179 }
180 Ok(())
181}
182
183#[cfg(test)]
184mod tests;