Skip to main content

gatehouse/
lib.rs

1#![forbid(unsafe_code)]
2
3//! An in-process authorization engine for Rust.
4//!
5//! Gatehouse keeps authorization logic in Rust while giving policy code a
6//! request-scoped fact session for relationship and backend-loaded data. The
7//! public API is centered on one [`PolicyDomain`] marker per authorization
8//! domain, a [`PermissionChecker`] that owns that domain's policy stack, and a
9//! [`BoundEvaluator`] created for one request/session/subject/action/context.
10//!
11//! # Overview
12//!
13//! A [`Policy`] is an asynchronous decision unit for one [`PolicyDomain`]. The
14//! domain names the four Rust types involved in a decision:
15//!
16//! - `Subject`: the caller.
17//! - `Action`: the operation being attempted.
18//! - `Resource`: the target resource or scope resource.
19//! - `Context`: request-scoped inputs such as current time, MFA freshness,
20//!   network zone, tenant config, or feature flags.
21//!
22//! Relationship data and other backend-loaded authorization facts do not
23//! belong in `Context`; expose them as [`FactKey`] values loaded by an
24//! [`EvaluationSession`]. The session batches, deduplicates, caches, and
25//! coalesces fact loads for one request.
26//!
27//! # Quick Start
28//!
29//! The fastest way to define a synchronous predicate policy is
30//! [`PolicyBuilder`]:
31//!
32//! ```rust
33//! # use gatehouse::*;
34//! #[derive(Debug, Clone)]
35//! struct User {
36//!     id: u64,
37//!     roles: Vec<&'static str>,
38//! }
39//! #[derive(Debug, Clone)]
40//! struct Document {
41//!     owner_id: u64,
42//! }
43//! #[derive(Debug, Clone)]
44//! struct ReadAction;
45//!
46//! struct Documents;
47//! impl PolicyDomain for Documents {
48//!     type Subject = User;
49//!     type Action = ReadAction;
50//!     type Resource = Document;
51//!     type Context = ();
52//! }
53//!
54//! let admin_policy = PolicyBuilder::<Documents>::new("AdminOnly")
55//!     .subjects(|user: &User| user.roles.contains(&"admin"))
56//!     .build();
57//!
58//! let owner_policy = PolicyBuilder::<Documents>::new("OwnerOnly")
59//!     .when(|user: &User, _action: &ReadAction, document: &Document, _ctx: &()| {
60//!         user.id == document.owner_id
61//!     })
62//!     .build();
63//!
64//! let mut checker = PermissionChecker::<Documents>::new();
65//! checker.add_policy(admin_policy);
66//! checker.add_policy(owner_policy);
67//!
68//! # tokio_test::block_on(async {
69//! let session = EvaluationSession::empty();
70//! let document = Document { owner_id: 7 };
71//! let admin = User { id: 1, roles: vec!["admin"] };
72//! let owner = User { id: 7, roles: vec!["user"] };
73//! let guest = User { id: 2, roles: vec!["user"] };
74//!
75//! assert!(checker.bind(&session, &admin, &ReadAction, &()).check(&document).await.is_granted());
76//! assert!(checker.bind(&session, &owner, &ReadAction, &()).check(&document).await.is_granted());
77//! assert!(!checker.bind(&session, &guest, &ReadAction, &()).check(&document).await.is_granted());
78//! # });
79//! ```
80//!
81//! # Core Flows
82//!
83//! Bind request-wide inputs once, then evaluate resources through the bound
84//! evaluator:
85//!
86//! ```rust,ignore
87//! let session = registry.session();
88//! let bound = checker.bind(&session, &subject, &action, &request_context);
89//!
90//! let decision = bound.check(&resource).await;
91//! let decisions = bound.evaluate(resources.clone()).await;
92//! let authorized = bound.filter(resources).await;
93//! let authorized_rows = bound.filter_by(rows, |row| &row.authz_resource).await;
94//! let page = bound.lookup_page(&lookup, &hydrator, cursor.as_deref(), limit).await?;
95//! ```
96//!
97//! Use [`EvaluationSession::empty`] for fact-free decisions. Use a session from
98//! [`FactRegistry::session`] when any policy calls `ctx.session.get(...)`, such
99//! as [`RebacPolicy`] or a custom fact-backed policy.
100//!
101//! [`BoundEvaluator::evaluate`] preserves input order and returns one
102//! [`AccessEvaluation`] per input resource. [`BoundEvaluator::filter`] keeps
103//! only granted resources. [`BoundEvaluator::evaluate_by`] and
104//! [`BoundEvaluator::filter_by`] are for wide caller-owned rows where
105//! authorization uses a projected resource. [`BoundEvaluator::lookup_page`] is
106//! for list endpoints where the application cannot load every possible
107//! candidate first; the [`LookupSource`] enumerates candidate IDs, a
108//! [`Hydrator`] resolves them, and the full policy stack authorizes the
109//! hydrated resources.
110//!
111//! # Decision Semantics
112//!
113//! Gatehouse deliberately keeps combining semantics fixed:
114//!
115//! - [`PermissionChecker`] applies deny-overrides. Any evaluated result
116//!   containing [`PolicyEvalResult::Forbidden`] denies the request and
117//!   overrides grants.
118//! - Policies declaring [`Effect::Forbid`] or [`Effect::AllowOrForbid`] are
119//!   evaluated before allow-only policies so a veto cannot be skipped by grant
120//!   short-circuiting.
121//! - If no policy forbids, the first grant wins.
122//! - If nothing grants, the checker denies with `"All policies denied access"`.
123//! - An empty checker denies with `"No policies configured"`.
124//! - [`PolicyEvalResult::NotApplicable`] means the policy did not grant.
125//!   [`PolicyEvalResult::Forbidden`] means the policy actively vetoed.
126//! - [`PolicyBuilder`] combines configured predicates with AND logic.
127//!   [`PolicyBuilder::forbid`] makes a matching built policy forbid; a
128//!   non-match remains not applicable and does not block.
129//! - [`AndPolicy`] and [`OrPolicy`] evaluate veto-capable children before
130//!   allow-only children, then short-circuit normally. [`NotPolicy`] inverts
131//!   grants and non-grants, but never turns `Forbidden` into a grant.
132//! - `Forbidden` propagates through [`AndPolicy`], [`OrPolicy`], [`NotPolicy`],
133//!   and [`DelegatingPolicy`].
134//! - [`NotPolicy`] does not neutralize a veto. `admin.or(blocked.not())` still
135//!   denies when `blocked` returns `Forbidden`. For "grant unless blocked", use
136//!   an allow-only `blocked` predicate under `not()`, or register an explicit
137//!   forbid policy when the block should be global.
138//! - `grant.and(forbid_only)` can never grant: a forbid-only child does not
139//!   satisfy AND's "all children grant" rule. Use
140//!   `grant.and(blocked_allow_predicate.not())` for a local exclusion.
141//!
142//! Denials from [`AccessEvaluation`] are summary-level. Use
143//! [`AccessEvaluation::display_trace`] or the attached [`EvalTrace`] to inspect
144//! individual policy reasons and fact provenance.
145//!
146//! # Fact-Loaded Authorization
147//!
148//! [`FactSource::load_many`] receives unique fact keys and must return exactly
149//! one result per key in the same order. [`EvaluationSession`] expands
150//! duplicate caller inputs, preserves caller order, caches results for the
151//! request, chunks loads according to [`FactSource::max_batch_size`], and joins
152//! concurrent in-flight loads for the same key.
153//!
154//! [`RebacPolicy`] is the built-in fact-backed policy. It extracts flat
155//! subject/resource IDs, builds [`RelationshipQuery`] keys, and grants only
156//! when the request session loads a `Found(true)` relationship fact. Missing
157//! sources, missing facts, backend errors, and fact-source contract violations
158//! fail closed to denied ReBAC decisions.
159//!
160//! # Long-Lived Streams
161//!
162//! [`EvaluationSession`] caches are scoped to one authorization pass. For SSE,
163//! WebSocket, and other long-lived streams, do not hold one fact-backed session
164//! for the stream lifetime.
165//!
166//! If your product contract authorizes once at stream open, create a fresh
167//! session, compute the visible ID set with [`BoundEvaluator::filter`] or
168//! [`BoundEvaluator::filter_by`], drop the session, and only emit frames for
169//! that set. If the stream must observe mid-stream permission revocation, run
170//! periodic reauthorization with a fresh [`FactRegistry::session`] and re-bind
171//! the checker for that pass.
172//!
173//! # Built-In Policies
174//!
175//! - [`RbacPolicy`]: role-based access control from caller roles and required
176//!   roles for the `(action, resource)` pair.
177//! - [`RebacPolicy`]: relationship-based access control backed by
178//!   [`FactSource`] and [`EvaluationSession`].
179//! - [`DelegatingPolicy`]: maps the current inputs into another
180//!   [`PolicyDomain`] and delegates to a child [`PermissionChecker`].
181//!
182//! Use [`PolicyBuilder::when`] for attribute-style predicates that compare
183//! subject, action, resource, and context in one synchronous closure.
184//!
185//! # Custom Policies
186//!
187//! Implement [`Policy`] directly when a rule needs async work, custom batching,
188//! custom telemetry metadata, or hand-written forbid behavior:
189//!
190//! ```rust
191//! # use async_trait::async_trait;
192//! # use std::borrow::Cow;
193//! # use gatehouse::*;
194//! # #[derive(Debug, Clone)] struct User { id: u64 }
195//! # #[derive(Debug, Clone)] struct Document { owner_id: u64 }
196//! # #[derive(Debug, Clone)] struct ReadAction;
197//! # struct Documents;
198//! # impl PolicyDomain for Documents {
199//! #     type Subject = User;
200//! #     type Action = ReadAction;
201//! #     type Resource = Document;
202//! #     type Context = ();
203//! # }
204//! struct OwnerPolicy;
205//!
206//! #[async_trait]
207//! impl Policy<Documents> for OwnerPolicy {
208//!     async fn evaluate(&self, ctx: &EvalCtx<'_, Documents>) -> PolicyEvalResult {
209//!         if ctx.subject.id == ctx.resource.owner_id {
210//!             ctx.grant("subject owns the document")
211//!         } else {
212//!             ctx.not_applicable("subject does not own the document")
213//!         }
214//!     }
215//!
216//!     fn policy_type(&self) -> Cow<'static, str> {
217//!         Cow::Borrowed("OwnerPolicy")
218//!     }
219//! }
220//! ```
221//!
222//! # Tracing
223//!
224//! When trace-level events are enabled, checker evaluation records spans for
225//! single-resource and batch evaluation, and each evaluated policy records a
226//! `trace!` event on the `gatehouse::security` target. Batch evaluation also
227//! records per-policy counts on nested `gatehouse.batch_policy` spans.
228
229#![warn(missing_docs)]
230#![allow(clippy::type_complexity)]
231
232mod builder;
233mod checker;
234mod combinators;
235mod facts;
236mod lookup;
237mod metadata;
238mod policies;
239mod policy;
240mod results;
241mod session;
242
243pub use builder::PolicyBuilder;
244pub use checker::{BoundEvaluator, PermissionChecker};
245pub use combinators::{AndPolicy, EmptyPoliciesError, NotPolicy, OrPolicy, PolicyExt};
246pub use facts::{FactKey, FactLoadError, FactLoadResult, FactSource, RelationshipQuery};
247pub use lookup::{Hydrator, LookupAuthorizedError, LookupAuthorizedPage, LookupPage, LookupSource};
248pub use metadata::SecurityRuleMetadata;
249pub(crate) use metadata::{DEFAULT_SECURITY_RULE_CATEGORY, PERMISSION_CHECKER_POLICY_TYPE};
250pub use policies::{DelegatingPolicy, RbacPolicy, RebacPolicy};
251pub use policy::{BatchEvalCtx, Effect, EvalCtx, Policy, PolicyBatchItem, PolicyDomain};
252pub use results::{
253    AccessEvaluation, CombineOp, EvalTrace, FactOutcome, FactProvenance, PolicyEvalResult,
254};
255pub use session::{EvaluationSession, FactRegistry, FactRegistryBuilder};
256
257// The shared unit-test module pulls in tokio-based async tests via dev-deps
258// that are intentionally loom-incompatible (`tokio::net`, axum, hyper, etc.).
259// Gate it out under `cfg(loom)` so the loom build's minimal dependency graph
260// stays clean. The synchronous core's deterministic tests and the loom
261// permutation tests both live in `src/session/core.rs` and are unaffected.
262#[cfg(all(test, not(loom)))]
263mod tests;