Sentinel is the authorization engine where list queries are provably consistent with point checks, and every policy decision is auditable retroactively.
An embeddable Rust authorization library implementing an intensional NGAC-inspired policy graph.
Every incumbent authorization engine (OPA, Cedar, Cerbos, SpiceDB) treats list-filtering as a derived approximation of the policy: they emit residual programs (Rego, CEL, partial-eval ASTs) that the application must interpret or reject. Approximation is unavoidable because their policy languages are Turing-complete.
Sentinel inverts the design: the matcher language is deliberately restricted so that the policy representation is the filter representation. evaluate() and scope() are two projections of the same AttributeMatcher set:
scope()output translates tokey IN (values…)exactly — no residuals, no escape hatch, no approximation.- A resource admitted by
scopeconstraints is guaranteed to be allowed byevaluate, and vice versa. - This agreement is a tested invariant, not a best-effort claim. Property-based tests sweep arbitrary graph configurations and assert the biconditional holds for all of them.
The matcher vocabulary has four variants: All (wildcard — matches any resource), Matching { key, values } (value-set membership — resource[key] ∩ values ≠ ∅), Relative { resource_key, subject_key } (co-membership — resource[resource_key] ∩ subject[subject_key] ≠ ∅, useful for same-org or same-team constraints without enumerating values in the policy graph), and AllOf(members) (conjunction — matches when every member matches, letting a single OA express a compound "AND" condition such as "self-hosted and owned by my org").
Give up Turing-complete policy expressiveness; gain provably exact query filters. No shipping engine offers this as a guarantee.
AccessScope::Constrained carries a Vec<Vec<ScopeConstraint>> — a CNF (conjunctive normal form) filter. The outer Vec combines its clauses with AND; each inner Vec combines its constraints with OR:
WHERE (clause1) AND (clause2) AND …
For a graph with no restrictive policy classes, there is exactly one clause — the shape below is unchanged from before restrictive PCs existed, just wrapped one level. Within that single clause, constraints combine with OR, never AND. Each constraint is an independent grant; the accessible set is the union of resources matching any one of them:
Constrained([[
Attribute { key: "org_id", values: ["alpha"] },
Attribute { key: "owner_id", values: ["u-1"] },
]])
// →
WHERE org_id IN ('alpha') OR owner_id IN ('u-1')
ANDing the constraints instead (WHERE org_id IN ('alpha') AND owner_id IN ('u-1')) silently under-returns and breaks the scope/evaluate equivalence the whole design rests on.
This also means multi-axis "AND" policies across independent grants (e.g., "org alpha and low-sensitivity" expressed as two separate associations) are not expressible this way — doing so does not intersect the two conditions, it unions them, broadening access beyond what was intended.
If your policy needs a genuine AND, author a single OA with an AttributeMatcher::AllOf matcher. scope() resolves it to a ScopeConstraint::AllOf group whose members combine with AND, nested inside the same top-level OR as every other constraint:
Constrained([[
Attribute { key: "org_id", values: ["alpha"] },
AllOf([
Attribute { key: "provider_type", values: ["self_hosted"] },
Attribute { key: "owner_organization_id", values: ["alpha"] },
]),
]])
// →
WHERE org_id IN ('alpha')
OR (provider_type IN ('self_hosted') AND owner_organization_id IN ('alpha'))
Within a clause, scope() only ever emits this shape one level deep: every clause is a flat list where each element is either a leaf Attribute (non-empty values) or an AllOf group of ≥2 leaf Attribute members — never AllOf nested inside AllOf. Downstream translation is therefore a two-case match per clause, no recursion required, and graphs with no AllOf matcher produce a single clause with byte-identical contents to before this variant existed.
A restrictive policy class (see NGAC lineage and intentional deviation) adds one CNF clause per satisfiable domain object attribute, encoding "if the resource is contained in this policy class, it must also be granted under it" (¬contained ∨ granted). The negation is expressed as ScopeConstraint::NotAttribute { key, values } — the exact logical complement of Attribute: it admits a resource iff its value-set for key has an empty intersection with values.
Constrained([
[Attribute { key: "org_id", values: ["alpha"] }], // base clause
[NotAttribute { key: "region", values: ["eu"] }], // restrictive clause
])
// →
WHERE (org_id IN ('alpha'))
AND (region IS NULL OR region NOT IN ('eu'))
Absent key and empty value-set both satisfy NotAttribute — a resource with no region attribute recorded is admitted by the second clause, exactly as evaluate() would allow it. This is where the naive SQL translation breaks:
-
key NOT IN (values…)alone is WRONG for a nullable column. SQL's three-valued logic makesNULL NOT IN (…)evaluate toNULL(notTRUE), so rows with no value forkeyare silently excluded — the query under-returns relative toevaluate(), breaking the soundness invariant. Use(key IS NULL OR key NOT IN (values…))instead. -
For a multi-valued column (join table or array column), there is no single nullable column to check against; use a
NOT EXISTSinstead:NOT EXISTS ( SELECT 1 FROM resource_attrs a WHERE a.resource_id = r.id AND a.key = 'region' AND a.value IN ('eu') )
NotAttribute never appears nested inside an AllOf — negating a conjunctive domain matcher (¬(a ∧ b) = ¬a ∨ ¬b) is pushed to multiple NotAttribute leaves OR'd within the same clause instead. Graphs with no restrictive policy classes never emit NotAttribute at all and always resolve to the single-clause shape shown above — the CNF wrap is the only mechanical migration required for existing consumers. See restrictive-policy-class.rs for a full worked example with evaluate() and scope() side by side.
Because the policy graph is an epoch event stream, sentinel can reconstruct the exact policy state at any past instant and run the same authorization queries against it.
This answers questions no mainstream engine can answer as a first-class guarantee:
- "Who could have accessed resource X at the time of the incident?"
- "Enumerate everything this contractor's grants could reach during their engagement."
- "Produce evidence for the Q1 access review as of the review date."
API: evaluate_at(t, req), scope_at(t, req), reachable_at(t, subject, vocabulary).
Sentinel implements the core NGAC (Next Generation Access Control) graph model — User (U), User Attribute (UA), Object Attribute (OA), Policy Class (PC) nodes with assignment and association edges — but deviates from the NIST standard in one deliberate way: resources are never stored in the graph.
Instead, OA nodes carry attribute predicates (key + value set) that match resources at query time. This is intensional NGAC: the graph encodes which resources belong to a scope rather than enumerating them. The graph stays O(policies) regardless of data volume. NIST's reference implementation is O(resources); sentinel's design is O(policies).
Sentinel also deviates on policy class semantics. Strict NIST NGAC requires intersection: an object must be contained in at least one policy class, and access is the intersection of grants across every containing PC. That default would break the direct UA→OA pattern (grants with no PC at all) and the common org-member pattern, so ordinary policy classes in sentinel stay existential-only — a grant under any containing PC suffices, as if PCs didn't constrain access. A policy class can opt in to NIST's intersection semantics per-PC via restrictive: true: any resource contained in that PC's domain then additionally requires a separate grant under it, layered on top of whatever grant already applies. It never grants by itself and defaults to false, so pre-existing graphs and event logs replay byte-for-byte unchanged. See restrictive-policy-class.rs for a worked example.
| Capability | NGAC standard | Sentinel |
|---|---|---|
| Graph cardinality | O(resources) — objects are nodes | O(policies) — resources matched by predicate, never stored |
| List-query filter consistency | Not addressed | Tested invariant — scope output is provably consistent with evaluate |
| Policy audit / time-travel | Not addressed | First-class — event-sourced graph, full retroactive reconstruction |
This table covers only the dimensions where sentinel makes a specific claim. For everything else (policy expressiveness, multi-language support, tooling), established engines (Cedar, Cerbos, OPA, SpiceDB) are ahead.
| Capability | Cedar | Cerbos | OPA | SpiceDB | Sentinel |
|---|---|---|---|---|---|
| List-query filter | Partial eval (experimental, residual AST) | PlanResources (residual CEL, may return CONDITIONAL) |
Partial eval (residual Rego) | Not applicable (tuple-based) | Exact constraints, no residuals — proven consistent with point check |
| Retroactive access audit | Decision logs only | Decision logs only | Decision logs only | Watch API (current state) | Full policy reconstruction at any past timestamp |
evaluate() and scope() decide purely on the subject_attrs/resource_attrs maps the caller supplies — sentinel cannot distinguish an authenticated claim from a client-supplied string. Subject attributes must come from authenticated identity (session, JWT, or IdP response), and resource attributes must come from the system of record (your database), never from client-supplied request data. Forwarding client-controlled values into either map makes any policy decided against it bypassable.
Securing the policy-mutation commands themselves (who may call into PolicyAggregate's command handling, or the lower-level PolicyGraph::add_* mutators used for event replay and test-graph construction) is likewise the consuming application's responsibility — sentinel does not gate its own admin surface.
- Attribute-matching model: Resources are not nodes in the graph. OA nodes carry metadata about which resource attributes they match, keeping the graph small regardless of data volume.
- NGAC graph: 4 node types (User, User Attribute, Object Attribute, Policy Class) with assignment edges and association edges carrying access rights.
- Two enforcement modes: Point checks (
evaluate) for command authorization; scope resolution (scope) for producing exact query filter constraints. - Event-sourced: The policy graph is persisted via epoch. Full audit trail, replay, and time-travel reconstruction are free.
| Crate | Description |
|---|---|
sentinel_core |
Pure graph model, traits, PEP evaluation, scope resolution, time-travel |
sentinel_derive |
Proc macros for policy enforcement annotations |
sentinel |
Facade crate with feature-gated re-exports |
org-scoped-access.rs— Multi-tenant org isolation withMatching: employees read jobs within their own organisation. Showsevaluate()andscope().ownership.rs— Per-record ownership withRelative: users read and delete their own documents. No user IDs stored in the policy graph — the constraint resolves from live request attributes.conjunctive-access.rs— Compound conditions withAllOf: org admins manage self-hosted machine definitions owned by their own organisation, in one auditable OA instead of a hand-rolledANDcheck outside the graph.restrictive-policy-class.rs— Per-PC intersection with arestrictivepolicy class: an EU-data control layer that requires a separate grant on top of an ordinary org-scoped grant. Shows the CNFscope()output and theNotAttribute/NULL-safe SQL translation.
Run an example:
cargo run --example org-scoped-access
cargo run --example ownership
cargo run --example conjunctive-access
cargo run --example restrictive-policy-classEarly development. See docs/ for design documents and specs/ for implementation specifications.