Skip to main content

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;