Capabilities
Authorization engine
RBAC + ABAC with a formal grammar — service:scope:action permissions, per-segment wildcards, deny-overrides, a JSON condition AST, one evaluation path for checks and visibility, and golden vectors that pin the .NET and TypeScript evaluators to each other.
Sentinel’s authorization engine is a hybrid RBAC + ABAC evaluator with one unusual
property: it is specified by data. A single golden-vector suite defines what every
grammar edge case and every evaluation rule means, and both the .NET evaluator and the
TypeScript client’s can() are tested against it. The server and the browser cannot
drift apart without a test failing.
The grammar
Permissions are service:scope:action, lowercase, with scope one of
global | org | team | self:
docs:org:read a concrete permission (PermissionId)
docs:org:can_view_* a grant pattern: wildcard action suffix
*-api:org:read wildcard service prefix
docs:*:read any scope
*:*:* everything (the classic admin grant)
Two distinct types keep checks and grants honest:
PermissionId— always concrete; what code checks.Parse/TryParsereject wildcards, uppercase, wrong segment counts, unknown scopes.PermissionPattern— what grants hold. At most one*per service/action segment (prefix, suffix or infix); the scope segment must be*or a concrete scope —docs:or*:readis rejected, because a partial scope wildcard is a typo, not a policy.Matches(in PermissionId)is allocation-free.
Grammar constraints. Segments are lowercase
[a-z0-9_-]+— dots are not allowed inside a segment. If you’re porting ids likepatients.notes:org:reador dotted action names (audit.read), rename to_or-(patients_notes:org:read,audit_read):ParsethrowsFormatExceptionon the dot, and declarative config / definition sync fail the boot rather than register an uncheckable permission.
Grants and evaluation
A Grant is a pattern + effect (Allow / Deny), optionally narrowed by organization,
teams, and an ABAC condition, with a source string (role:org-admin, group:oncall)
kept purely for attribution — evaluation never consults it. Roles, groups and policies
all flatten to grants when the per-(user, org) SubjectSnapshot is built; the
evaluator itself has no concept of a role.
var decision = AuthorizationEvaluator.Evaluate(subject, new AccessCheck(
PermissionId.Parse("records:org:read_chart"),
resourceOrganizationId: chartOrgId,
resourceOwnerId: chartOwnerId));
if (decision.IsAllowed) { … }
The semantics, in order of authority:
- Default deny. No matching grant →
DeniedByDefault. - Deny overrides. Any applicable deny beats every allow, across the whole subject — role grants, group grants, direct grants. Without a trace attached, the first applicable deny short-circuits; the golden vectors run every case both traced and untraced to pin that the answer is identical.
- Scope must apply.
globalalways;orgrequires an org context and the grant’s org (if any) must equal the effective org;teamrequires the resource’s team ids to intersect the grant’s (or the subject’s) teams;selfrequiresresourceOwnerId == subjectId. Cross-org rule: when the resource’s org differs from the token’s org, realm-wide grants do not leak across. - Conditions must hold — the ABAC layer, below.
ABAC: the condition AST
Conditions are a versioned JSON document evaluated over three attribute bags —
subject.* (the user’s JSON attribute bag), resource.* and context.* (supplied per
check):
{
"version": 1,
"condition": {
"allOf": [
{ "op": "eq", "attr": "resource.department", "value": { "ref": "subject.department" } },
{ "op": "in", "attr": "subject.clearance", "value": ["secret", "top-secret"] }
]
}
}
Combinators allOf / anyOf / not; operators
eq, ne, in, contains, gt, gte, lt, lte, exists; { "ref": … } compares attributes to
attributes. The semantics are deliberately paranoid: cross-type comparisons are false
(never coerced), numbers-only ordering operators never throw, and a missing attribute
makes every comparison false — including ne. An absent attribute cannot satisfy a
condition; fail-closed extends into the ABAC layer.
One evaluation path: visibility
VisibilityFor(subject, service, action, scope) answers the list question — “should
this query even run, and does it need row filters?” — with Granted, Conditional or
None. It is computed from the same grant-matching primitives as Evaluate, so
visibility can never be broader than a row-by-row check. Anything row-dependent — ABAC
conditions, team restrictions, per-row denies, and every team/self scope — comes back
Conditional; a conditioned deny downgrades Granted to Conditional rather than being
ignored. This is the explicit fix for the divergence the Node-era visibilityFor had.
Tracing and the inspector
The hot path is allocation-free; the explain path is opt-in. Pass an EvaluationTrace
and every grant reports its outcome — PatternMismatch, ScopeNotApplicable,
ConditionFailed, Allowed, Denied. The admin surface exposes this as
POST /sentinel-admin/authz/inspect (via AuthorizationInspector, which loads a fresh
snapshot, never the cache) — “why can Priya see this record?” is a query, not an
archaeology project.
Snapshots and caching
ISubjectDataSource.LoadAsync(userId, organizationId, ct) produces the raw grant data;
SubjectSnapshotBuilder compiles it, skipping-and-reporting invalid rows rather than
failing the login. SubjectSnapshotCache is bounded (TTL + LRU) and subscribes to the
cache bus for invalidation. Snapshots are per-(user, org) — an org switch is a
different snapshot, not a filtered one.
What browsers receive is redacted: GET /profile/permissions returns allow-effect
patterns and team ids only. Deny grants, conditions, org scoping and provenance never
leave the server.
Golden vectors
The suite in golden-vectors/authz/ — pattern.json (33 cases), conditions.json (32),
evaluate.json (28), visibility.json (12) — is consumed by the .NET tests
(GoldenVectorTests) and the TypeScript client’s test suite. Property-based FsCheck
tests add the invariants the vectors can’t enumerate (deny-overrides is
order-independent; tighten-only merges are monotone).
In practice
- Declare permissions in code and let definition-sync reconcile them at boot — an unpublished permission id fails the boot, not the request.
- Attach checks in Relay apps with
[RequirePermission("records:org:read")]+[RequirePolicy(SentinelAuthorizationPolicy.PolicyName)]via the Relay bridge. - Read article 002 — The permission grammar for a guided tour with the vectors as the syllabus, and article 005 for how delegated admin rides the same evaluator.
Learn by building
The tutorials for this area, in order — each with a runnable sample.