Gatehouse Authorization Engine

repository·main·Indexed 43 days ago

https://github.com/thepartly/gatehouse

An in-process authorization engine for Rust (v0.6.0-alpha.1) supporting RBAC, ReBAC, and predicate-based policies. It features composable policies, request-scoped fact loading via EvaluationSession to prevent N+1 query problems, and a type-safe PolicyDomain system for managing subjects, actions, resources, and contexts.

Tokens
27.5K
Snippets
70
Records
98
Agent score
56%

What's inside Gatehouse

  1. How request-scoped facts and EvaluationSession work

    main

    Gatehouse uses EvaluationSession to manage the lifecycle of facts during an authorization pass. When a policy requires external data (facts), the FactSource::load_many method is called to fetch them.

    Key behaviors of EvaluationSession:

    • Caching: Results are cached for the duration of a single request/session to prevent redundant loads.
    • Deduplication & Ordering: It expands duplicate caller inputs and preserves the original caller order.
    • Batching: It chunks loads according to the FactSource::max_batch_size to optimize backend calls.
    • Concurrency: It joins concurrent in-flight loads for the same key.

    FactSource Contract: FactSource::load_many must receive unique keys and return exactly one result per key in the same order as requested.

    Policy Behavior: RebacPolicy is the built-in fact-backed policy. Decisions follow these rules:

    • Found(false) and Missing result in an abstention.
    • Missing sources, backend errors, or contract violations produce indeterminate decisions.
  2. Define a PolicyDomain to simplify generics

    main

    In version 0.5, instead of repeating four generic parameters (Subject, Action, Resource, Context) on every policy and builder, you define a single PolicyDomain marker. This marker encapsulates the types for a specific authorization domain.

    Associated types must be implemented in the order: Subject, Action, Resource, Context.

    use gatehouse::PolicyDomain;
    
    struct User;
    struct ReadAction;
    struct Document;
    struct RequestContext;
    
    struct Documents;
    
    impl PolicyDomain for Documents {
        type Subject = User;
        type Action = ReadAction;
        type Resource = Document;
        type Context = RequestContext;
    }
    
    // Use the domain in policies and checkers
    type DocumentChecker = PermissionChecker<Documents>;
    
    impl Policy<Documents> for OwnerPolicy {
        async fn evaluate(&self, ctx: &EvalCtx<'_, Documents>) -> PolicyEvalResult {
            // ...
        }
    }
  3. Compose Grant and Veto Policies

    main

    Grant Combinators

    PolicyExt::and, or, and not accept grant policies only. You cannot embed a veto directly into these expressions. To implement a local exclusion (e.g., 'Allow if X and not Y'), use: grant.and(blocked.not()), where blocked is a predicate grant policy.

    Veto Combinators

    To compose multiple veto policies, use:

    • AllOfVeto / VetoPolicyExt::all_of: Every child must veto to block the request.
    • AnyOfVeto / VetoPolicyExt::any_of: Any single child veto will block the request.

    Delegation

    DelegatingPolicy is registered via checker.add_delegate(delegate). It cannot be used inside grant combinators (and, or, etc.). To achieve combined logic, compose the grant rules inside the child checker and register that child checker as a delegate. Be aware that a delegated veto blocks every parent grant path.

  4. Understand Authorization Decisions and Results

    main

    Gatehouse distinguishes between Grant Policies and Veto Policies to ensure clear authority separation.

    Grant Policies (Policy<D>)

    Return a GrantResult: grant, abstention, or indeterminate.

    Veto Policies (VetoPolicy<D>)

    Return a VetoResult: veto, pass, or indeterminate.

    Decision Logic

    1. Vetoes are resolved first: A definite veto denies access immediately. Unresolved veto uncertainty (indeterminate) also blocks grants.
    2. Grants are evaluated: If vetoes pass, any grant authorizes access. A failed grant policy does not defeat an independent grant.
    3. Final Outcome: If nothing grants, access is Denied. If there is uncertainty (e.g., from an indeterminate veto or failed fact load), access is Indeterminate. An empty checker denies access.

    Mapping to Application Boundaries

    Use evaluation.into_result() to convert an AccessEvaluation into a standard Rust Result.

    • Ok(()) $\rightarrow$ Access Granted.
    • Err(AccessError::Denied { .. }) $\rightarrow$ Map to a 403 Forbidden response.
    • Err(AccessError::Indeterminate { .. }) $\rightarrow$ Map to a 503 Service Unavailable or similar error.
    use gatehouse::{AccessError, AccessEvaluation, EvalTrace};
    
    fn http_status(evaluation: AccessEvaluation) -> u16 {
        match evaluation.into_result() {
            Ok(()) => 200,
            Err(AccessError::Denied { .. }) => 403,
            Err(_) => 503,
        }
    }
  5. How the Core Authorization Flow works

    main

    The typical authorization workflow involves binding request-wide inputs (session, subject, action, and context) to a PermissionChecker to create a BoundEvaluator. This evaluator is then used to check individual resources or perform batch operations like filtering or paging.

    Common operations on a BoundEvaluator:

    • check(&resource): Evaluates a single resource.
    • evaluate(resources): Returns decisions for a collection of resources, preserving input order.
    • try_filter(resources): Returns only the granted items and fails if any decision is indeterminate.
    • try_filter_by(rows, |row| &row.authz_resource): Filters a collection of rows based on an extracted resource.
    • try_lookup_page(...): Handles paginated lookups using a LookupSource and Hydrator.
    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.try_filter(resources).await?;
    let authorized_rows = bound.try_filter_by(rows, |row| &row.authz_resource).await?;
    let page = bound.try_lookup_page(&lookup, &hydrator, cursor.as_deref(), limit).await?;
  6. Understand the FactSource N+1 vs Batched protection

    main

    The latency_fact_source benchmark group is designed to detect regressions where the system reverts from efficient batched loading to inefficient per-item loading (the N+1 problem).

    Key comparison points:

    • naive_per_item_sessions: Models the failure mode where an author calls Arc<dyn HierarchyService> directly from Policy::evaluate, resulting in one round trip per item.
    • checker_batch_one_session: The optimized path where one session and one bound filter call allow the FactSource to see a single load_many covering all N keys.

    A successful implementation shows the gap between these two widening as N increases, because the naive shape grows linearly with per-call overhead, while the batched shape grows primarily with the source's load_many work.

  7. Optimize policy evaluation with PolicyBuilder batch shortcuts

    main

    The policy_builder_subject_only_batch benchmark measures the optimization enabled by PolicyBuilder::build. This optimization allows the subject/action axes to be evaluated once per batch instead of once per item.

    When using PolicyBuilder, the resulting policy uses an overridden evaluate_batch that broadcasts the subject result across the batch. This provides significant performance gains as the batch size N increases.

    Performance Note: For maximum performance, use 'static names in your policies. While PolicyBuilder provides a shortcut for dynamic names, hand-written policies with 'static names remain the fastest baseline.

  8. Handle Policy Results: NotApplicable vs Denied

    main

    In version 0.5, PolicyEvalResult::Denied is no longer used for policy-level non-grants to avoid confusion with the final AccessEvaluation::Denied (the top-level result).

    • Use ctx.not_applicable("reason") or PolicyEvalResult::not_applicable("reason") when a policy simply does not apply to the current context.
    • AccessEvaluation::Denied remains the final result returned by the checker when no policy grants access and no vetoes are present.
    // After 0.5
    if ctx.subject.id == ctx.resource.owner_id {
        ctx.grant("owner")
    } else {
        ctx.not_applicable("not owner")
    }
  9. Migrate RbacPolicy and RebacPolicy to include domain generics

    main

    When migrating from version 0.5.1 to 0.6.0-alpha.1, RbacPolicy and RebacPolicy now require the domain as the first generic parameter.

    For RbacPolicy, the domain is the first type parameter. Type inference often handles the remaining closure types once the policy is added to a PermissionChecker<Domain>.

    For RebacPolicy, the domain is also the first type parameter, followed by the User ID type, Resource ID type, and Relation type.

    // RbacPolicy migration
    let policy = RbacPolicy::<Documents, _, _>::new(required_roles, subject_roles);
    
    // RebacPolicy migration
    let policy = RebacPolicy::<Documents, UserId, DocumentId, Relation>::new(
        |user| user.id,
        |document| document.id,
        Relation::Viewer,
    );
  10. Migrate from Gatehouse 0.4 to 0.5

    main

    Upgrading from version 0.4 to 0.5 involves significant API changes designed to reduce the authorization surface and prevent misuse. Key changes include moving from four generic parameters to a single PolicyDomain, requiring all checker calls to go through a session-bound evaluator, and changing how non-grants are handled.

    Migration Checklist

    1. Define one PolicyDomain marker per authorization domain.
    2. Change Policy<S, A, R, C> implementations to Policy<Domain>.
    3. Change EvalCtx<'_, S, A, R, C> to EvalCtx<'_, Domain>.
    4. Replace ctx.deny(...) with ctx.not_applicable(...).
    5. Replace PolicyEvalResult::Denied with PolicyEvalResult::NotApplicable.
    6. Replace PolicyBuilder::<S, A, R, C> with PolicyBuilder::<Domain>.
    7. Replace direct checker methods (e.g., check, evaluate_in_session) with the new binding pattern: checker.bind(...).check(...), .evaluate(...), .filter(...), or .lookup_page(...).
    8. Build request sessions through FactRegistry for fact-backed policies.
    9. Replace AbacPolicy with PolicyBuilder::when or a hand-written Policy.
    10. Update direct BatchEvalCtx / PolicyBatchItem construction in tests.
    11. Add wildcard arms to matches on public gatehouse enums (they are now #[non_exhaustive]).
  11. Handle authorization for long-lived streams (SSE/WebSockets)

    main

    Because EvaluationSession caches are scoped to a single authorization pass, you must not keep a single fact-backed session alive for the entire lifetime of a long-lived stream (like SSE or WebSockets).

    Patterns for Streams:

    1. Authorize once at open: Create a fresh session, compute the visible ID set using try_filter or try_filter_by, drop the session, and then only emit frames for that specific set.
    2. Observe mid-stream revocations: Run periodic reauthorization by creating a fresh registry.session() and re-binding the checker for each tick.
  12. Quick Start with Gatehouse

    main

    Gatehouse is an in-process authorization engine for Rust. To get started, define your authorization domain by implementing the PolicyDomain trait for a marker struct, then use PolicyBuilder to define rules and PermissionChecker to evaluate them.

    Key steps:

    1. Define your Subject, Action, Resource, and Context types.
    2. Implement PolicyDomain for a struct representing your domain.
    3. Create policies using PolicyBuilder (for synchronous predicates) or by implementing the Policy trait (for asynchronous logic).
    4. Add policies to a PermissionChecker.
    5. Bind the checker to a session and inputs to perform checks.
    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);
    
    let session = EvaluationSession::empty();
    let action = ReadAction;
    let document = Document { owner_id: 7 };
    let admin = User { id: 1, roles: vec!["admin"] };
    
    assert!(checker.bind(&session, &admin, &action, &()).check(&document).await.is_granted());