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/TryParse reject 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*:read is 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 like patients.notes:org:read or dotted action names (audit.read), rename to _ or - (patients_notes:org:read, audit_read): Parse throws FormatException on 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:

  1. Default deny. No matching grant → DeniedByDefault.
  2. 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.
  3. Scope must apply. global always; org requires an org context and the grant’s org (if any) must equal the effective org; team requires the resource’s team ids to intersect the grant’s (or the subject’s) teams; self requires resourceOwnerId == subjectId. Cross-org rule: when the resource’s org differs from the token’s org, realm-wide grants do not leak across.
  4. 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.