An in-process authorization engine for Rust. Gatehouse keeps policy logic in Rust while giving each request an EvaluationSession for relationship and backend-loaded facts. Sessions batch, deduplicate, cache, and coalesce fact loads, so list endpoints can stay policy-correct without pushing authorization logic into the data layer.
- Typed authorization domains: Define one
PolicyDomainper authorization domain and keep subject, action, resource, and context types consistent. - Request-bound evaluation: Bind session, subject, action, and context once, then call
check,evaluate,evaluate_by,filter,filter_by, orlookup_page. - RBAC, ReBAC, and predicate policies: Use
RbacPolicy,RebacPolicy, or synchronousPolicyBuilder::whenpredicates. - Deny-overrides semantics:
PolicyBuilder::forbid()and custom forbid results veto grants, including through combinators and delegation. - Batch-safe list endpoints: Authorize already-loaded resources or enumerate candidate IDs with
LookupSourceandHydrator. - Evaluation traces and telemetry: Inspect the policies and fact provenance that were actually evaluated.
use gatehouse::*;
#[derive(Debug, Clone)]
struct User {
id: u64,
roles: Vec<&'static str>,
}
#[derive(Debug, Clone)]
struct Document {
owner_id: u64,
}
#[derive(Debug, Clone)]
struct ReadAction;
struct Documents;
impl PolicyDomain for Documents {
type Subject = User;
type Action = ReadAction;
type Resource = Document;
type Context = ();
}
let admin_policy = PolicyBuilder::<Documents>::new("AdminOnly")
.subjects(|user: &User| user.roles.contains(&"admin"))
.build();
let owner_policy = PolicyBuilder::<Documents>::new("OwnerOnly")
.when(|user: &User, _action: &ReadAction, doc: &Document, _ctx: &()| {
user.id == doc.owner_id
})
.build();
let mut checker = PermissionChecker::<Documents>::new();
checker.add_policy(admin_policy);
checker.add_policy(owner_policy);
# tokio_test::block_on(async {
let session = EvaluationSession::empty();
let action = ReadAction;
let document = Document { owner_id: 7 };
let admin = User { id: 1, roles: vec!["admin"] };
let owner = User { id: 7, roles: vec!["user"] };
let guest = User { id: 2, roles: vec!["user"] };
assert!(checker.bind(&session, &admin, &action, &()).check(&document).await.is_granted());
assert!(checker.bind(&session, &owner, &action, &()).check(&document).await.is_granted());
assert!(!checker.bind(&session, &guest, &action, &()).check(&document).await.is_granted());
# });Use EvaluationSession::empty() for fact-free checkers. When any policy reads facts through ctx.session.get(...), build a FactRegistry at application setup and create a fresh registry.session() for each request.
Most call sites bind request-wide inputs once and evaluate one or more resources through the bound evaluator:
let session = registry.session();
let bound = checker.bind(&session, &subject, &action, &request_context);
let decision = bound.check(&resource).await;
let decisions = bound.evaluate(resources.clone()).await;
let authorized = bound.filter(resources).await;
let authorized_rows = bound.filter_by(rows, |row| &row.authz_resource).await;
let page = bound.lookup_page(&lookup, &hydrator, cursor.as_deref(), limit).await?;flowchart LR
Request[request] --> Session[EvaluationSession]
Request --> Bound[BoundEvaluator]
Checker[PermissionChecker] --> Bound
Bound --> Policies[policies]
Policies --> Session
Session --> Facts[FactSource::load_many]
Facts --> Backend[(backend)]
Policies --> Decision[AccessEvaluation + EvalTrace]
BoundEvaluator::evaluate preserves input order and returns one AccessEvaluation per resource. BoundEvaluator::filter keeps only granted resources. Use evaluate_by and filter_by when the caller owns wider rows and authorization should project each row to an embedded resource. The returned values are still the original rows. BoundEvaluator::lookup_page is for list endpoints where the application cannot load every possible candidate first; a LookupSource enumerates candidate IDs, a Hydrator resolves them, and the full policy stack authorizes the hydrated resources.
PermissionCheckerapplies fixed deny-overrides semantics: any evaluated result containingPolicyEvalResult::Forbiddendenies; otherwise the first grant wins.- Policies declaring
Effect::ForbidorEffect::AllowOrForbidare evaluated before allow-only policies, so a veto cannot be skipped by grant short-circuiting. - If nothing grants, the checker denies with
"All policies denied access". - An empty checker denies with
"No policies configured". PolicyEvalResult::NotApplicablemeans the policy did not grant.PolicyEvalResult::Forbiddenmeans the policy actively vetoed.PolicyBuildercombines configured predicates with AND logic.PolicyBuilder::forbid()makes a matching policy forbid; a non-match remains not applicable and does not block.AndPolicyandOrPolicyevaluate veto-capable children before allow-only children, then short-circuit normally.NotPolicyinverts grants and non-grants, but never turnsForbiddeninto a grant.Forbiddenpropagates throughAndPolicy,OrPolicy,NotPolicy, andDelegatingPolicy.not()does not neutralize a veto:admin.or(blocked.not())still denies ifblockedreturnsForbidden. For "grant unless blocked", makeblockedan allow-only predicate and wrap that innot(), or register a direct forbid policy when the block should be global.
Denials from AccessEvaluation are summary-level. Use AccessEvaluation::display_trace() or the attached EvalTrace to inspect individual policy reasons and fact provenance.
A PolicyDomain names the four types involved in one authorization domain:
use gatehouse::PolicyDomain;
# struct User;
# struct DocAction;
# struct Document;
# struct RequestContext;
struct Documents;
impl PolicyDomain for Documents {
type Subject = User;
type Action = DocAction;
type Resource = Document;
type Context = RequestContext;
}The generic parameter then stays short and consistent:
let policy = PolicyBuilder::<Documents>::new("Owner")
.when(|user, _action, doc, _ctx| user.id == doc.owner_id)
.build();
let checker = PermissionChecker::<Documents>::new();Use PolicyBuilder for synchronous predicate logic:
let suspended_account = PolicyBuilder::<Documents>::new("SuspendedAccount")
.when(|user, _action, _doc, _ctx| user.is_suspended)
.forbid()
.build();Reach for a direct Policy<D> implementation when a rule needs async work, custom batching, custom telemetry metadata, or hand-written forbid behavior.
use async_trait::async_trait;
use gatehouse::{EvalCtx, Policy, PolicyDomain, PolicyEvalResult};
use std::borrow::Cow;
# #[derive(Debug, Clone)]
# struct User { id: u64 }
# #[derive(Debug, Clone)]
# struct Document { owner_id: u64 }
# #[derive(Debug, Clone)]
# struct ReadAction;
# struct Documents;
# impl PolicyDomain for Documents {
# type Subject = User;
# type Action = ReadAction;
# type Resource = Document;
# type Context = ();
# }
struct OwnerPolicy;
#[async_trait]
impl Policy<Documents> for OwnerPolicy {
async fn evaluate(&self, ctx: &EvalCtx<'_, Documents>) -> PolicyEvalResult {
if ctx.subject.id == ctx.resource.owner_id {
ctx.grant("subject owns the document")
} else {
ctx.not_applicable("subject does not own the document")
}
}
fn policy_type(&self) -> Cow<'static, str> {
Cow::Borrowed("OwnerPolicy")
}
}RbacPolicy: role-based access control. Grants when at least one required role for(action, resource)is present in the subject's roles.RebacPolicy: relationship-based access control. Extracts subject/resource IDs, buildsRelationshipQuerykeys, and grants when the request session loadsFound(true)from a registeredFactSource.DelegatingPolicy: maps inputs into anotherPolicyDomainand delegates to a childPermissionCheckerwhile preserving batching and trace shape.
Use PolicyBuilder::when for attribute-style predicates that compare subject, action, resource, and context in one closure.
Policies can be composed with the PolicyExt helpers:
use gatehouse::PolicyExt;
let rule = is_editor.and(not_locked.not()).or(admin_override);
checker.add_policy(rule);AndPolicy::try_new, OrPolicy::try_new, and NotPolicy::new remain available when constructing policies from dynamic collections.
forbid() creates a global veto, not a local negative predicate. In particular, grant.and(forbid_only) can never grant because the forbid-only child never satisfies AND's "all children grant" rule. For a local exclusion, build the blocked condition as an ordinary allow-style predicate and compose grant.and(blocked.not()).
FactSource::load_many receives unique keys and must return exactly one result per key in the same order. EvaluationSession expands duplicate caller inputs, preserves caller order, caches results for the request, chunks loads according to FactSource::max_batch_size, and joins concurrent in-flight loads for the same key.
flowchart LR
Policy[policy] --> Session[EvaluationSession]
Session --> Cache{cached?}
Cache -- yes --> Result[FactLoadResult]
Cache -- no --> Source[FactSource::load_many]
Source --> Backend[(backend)]
Backend --> Result
RebacPolicy is the built-in fact-backed policy. Missing sources, missing facts, backend errors, and source contract violations fail closed to denied ReBAC decisions.
Use typed relation enums when the domain has a fixed relation set, even if the backing store uses strings. The FactSource owns the backend boundary and can convert Relation::Viewer to "viewer" when binding SQL parameters.
For lists where the application already has candidate resources, use BoundEvaluator::filter:
let session = registry.session();
let visible_posts = checker
.bind(&session, &user, &PostAction::View, &request_context)
.filter(posts)
.await;If each item is a wide row and the authorization resource is a projection, use the extractor variants:
let visible_rows = checker
.bind(&session, &user, &InvoiceAction::View, &request_context)
.filter_by(rows, |row| &row.authz_resource)
.await;For lists where the candidate set is too large to load first, implement LookupSource<Domain> and Hydrator<Id>:
let page = checker
.bind(&session, &user, &PostAction::View, &request_context)
.lookup_page(&lookup, &hydrator, cursor.as_deref(), limit)
.await?;A LookupSource must enumerate a superset of every resource that any policy could grant for the bound subject/action/context. Lookup narrows candidates; it does not replace the policy stack.
EvaluationSession caches are scoped to one authorization pass. For SSE, WebSocket, and other long-lived streams, do not keep one fact-backed session for the stream lifetime.
If your product contract authorizes once at stream open, create a fresh session, compute the visible ID set with filter / filter_by, drop the session, and only emit frames for that set. If the stream must observe mid-stream permission revocation, run periodic reauthorization with a fresh registry.session() each tick and re-bind the checker for that pass.
When trace-level events are enabled, checker evaluation records spans for single-resource and batch evaluation, and each evaluated policy records a trace! event on the gatehouse::security target. Batch evaluation records aggregate item counts and nested gatehouse.batch_policy spans with per-policy counts.
Reason strings are emitted verbatim. Keep credentials, tokens, raw PII, and other sensitive material out of policy reasons and fact provenance details. Enable the optional serde feature to serialize AccessEvaluation, EvalTrace, PolicyEvalResult, and fact provenance for audit logs.
Security event fields:
security_rule.namesecurity_rule.categorysecurity_rule.descriptionsecurity_rule.referencesecurity_rule.ruleset.namesecurity_rule.uuidsecurity_rule.versionsecurity_rule.licenseevent.outcomepolicy.typepolicy.result.reason
Run a self-contained example with:
cargo run --example rbac_policyRun a server example with:
cargo run --example axumThen send requests to http://127.0.0.1:8000; actix_web listens on http://127.0.0.1:8080.
Examples to start with:
rbac_policy: basic role-based access control.policy_builder: attribute-style custom policies.combinator_policy: fluentand/or/notcomposition.deny_override: global veto policies withPolicyBuilder::forbid().delegating_policy: cross-domain checks withDelegatingPolicy.mfa_freshness_context: request-scoped context inputs.rebac_policy: relationship checks throughFactSource.in_ram_rebac: in-memory relationship facts and session caching.lookup_in_ram:LookupSourceplusHydratorlist authorization.factsource_n_plus_one: why request-scoped facts matter for list endpoints.axumandactix_web: web-framework integration.postgres_bulk_rebac: SQL-backed ReBAC fact loading.
Criterion benchmarks in benches/permission_checker.rs exercise bound checker evaluation, fact loading, batching, and policy-builder batch shortcuts. Run them with:
cargo benchThe postgres_bulk_rebac example demonstrates a SQL-backed ReBAC FactSource with one batched WITH ORDINALITY query per request. It expects a live PostgreSQL database and reads DATABASE_URL.