Articles · Authorization Engine
The permission grammar, evaluated
Try out the running example
Authorization bugs are quiet. Nothing crashes; the wrong person just reads the wrong record. Sentinel’s answer is an engine small enough to reason about, specified by a vector suite two implementations must pass. This article walks that engine bottom-up.
Ids check, patterns grant
Two types, deliberately not interchangeable:
// What code CHECKS — always concrete. Wildcards are rejected here.
var id = PermissionId.Parse("docs:org:read");
// id.Service == "docs", id.Scope == PermissionScope.Org, id.Action == "read"
// What grants HOLD — wildcards allowed, one per segment.
var pattern = PermissionPattern.Parse("docs:org:can_view_*");
pattern.Matches(PermissionId.Parse("docs:org:can_view_reports")); // true
The shape is service:scope:action, lowercase [a-z0-9_-]+, scope one of
global | org | team | self. Patterns allow a single * as prefix, suffix or infix in
the service and action segments; the scope segment is either concrete or exactly * —
docs:or*:read fails to parse, because a partial scope wildcard is always a typo.
Edge cases are pinned by golden-vectors/authz/pattern.json (33 cases), including the
subtle one: a*a does not match a — the candidate must be long enough to contain
both halves.
Grants, and the subject they attach to
var subject = new SubjectSnapshot(
subjectId: ada, realmId: realm, organizationId: clinicOrg,
teamMemberships: [cardiologyTeam],
grants:
[
// From a role — 'source' is attribution only, never semantics.
new Grant(PermissionPattern.Parse("records:org:read_*"), GrantEffect.Allow,
source: "role:clinician"),
// A direct deny. It will beat every allow, wherever it applies.
new Grant(PermissionPattern.Parse("records:org:read_billing"), GrantEffect.Deny,
source: "direct"),
// Team-scoped: applies only when the resource belongs to a shared team.
new Grant(PermissionPattern.Parse("records:team:annotate"), GrantEffect.Allow),
]);
The snapshot is per-(user, org) — roles, groups and hierarchical policies are all
flattened into this grant list by SubjectSnapshotBuilder before evaluation. The
evaluator has no idea roles exist, which is why there is exactly one set of semantics to
learn.
Evaluate
var decision = AuthorizationEvaluator.Evaluate(subject, new AccessCheck(
PermissionId.Parse("records:org:read_chart")));
// decision.IsAllowed == true (records:org:read_* allows, no deny matches)
var billing = AuthorizationEvaluator.Evaluate(subject, new AccessCheck(
PermissionId.Parse("records:org:read_billing")));
// billing.Outcome == AccessOutcome.DeniedByGrant — the deny wins over read_*
Per grant, the checks run in a fixed order — pattern, scope, condition, effect — and the overall rules are:
- Default deny: nothing matched →
DeniedByDefault. - Deny overrides: one applicable deny beats any number of allows, across the whole subject. FsCheck property tests assert this is order-independent — shuffling the grant list can never change the answer.
- Scoping:
globalalways applies;orgneeds an org context;teamneeds the check’sresourceTeamIdsto intersect the grant’s teams (or the subject’s);selfneedsresourceOwnerId == subjectId. And the cross-org rule: if the resource’s org differs from the token’s org, realm-wide grants don’t follow you across.
ABAC when the grant needs a condition
var condition = ConditionDocument.Parse("""
{
"version": 1,
"condition": {
"allOf": [
{ "op": "eq", "attr": "resource.department",
"value": { "ref": "subject.department" } },
{ "op": "gte", "attr": "context.assurance_level", "value": 2 }
]
}
}
""");
var grant = new Grant(PermissionPattern.Parse("records:org:read_sensitive"),
GrantEffect.Allow, condition: condition);
Attributes resolve from three bags — subject.* (the user’s JSON attribute bag),
resource.* and context.* (passed on the AccessCheck). The semantics are fail-closed
to the bone: cross-type comparisons are false, ordering operators only order numbers, and
a missing attribute falsifies every operator — including ne. If the data isn’t
there, the condition cannot pass. All 32 condition vectors in conditions.json encode
these decisions, including the parse-error cases that must throw.
The list problem, solved by the same code
Every app eventually asks the plural question: which records should this query return? Engines that answer it with separate logic drift. Sentinel derives visibility from the same grant-matching primitives:
var vis = AuthorizationEvaluator.VisibilityFor(subject, "records", "read_chart",
PermissionScope.Org);
// Granted → list freely, no row checks needed
// Conditional → run the query, but evaluate rows (or push filters down)
// None → skip the query entirely
Anything row-dependent — conditions, team restrictions, per-row denies, and every
team/self scope — returns Conditional. A conditioned deny downgrades Granted to
Conditional instead of being ignored: visibility can never promise more than
row-by-row evaluation would deliver.
Why should you believe any of this?
Because the spec is executable. golden-vectors/authz/ holds 105 cases across four
suites — pattern parsing/matching, conditions, evaluation, visibility — and two
implementations consume them: the .NET evaluator (GoldenVectorTests, which runs every
evaluation case twice, traced and untraced, asserting identical decisions) and the
TypeScript client that powers can() in the browser. When you gate a button with
can("records:org:read_chart"), the browser is running the same pinned semantics the
server will enforce — redacted to allow-patterns only, because deny grants never leave
the server.
Explaining a decision
Attach a trace and every grant reports what happened to it:
var trace = new EvaluationTrace();
AuthorizationEvaluator.Evaluate(subject, check, trace);
foreach (var e in trace.Entries)
Console.WriteLine($"{e.Pattern} {e.Effect} [{e.Source}] → {e.Outcome}");
// records:org:read_* Allow [role:clinician] → Allowed
// records:org:read_billing Deny [direct] → PatternMismatch
// records:team:annotate Allow [direct] → ScopeNotApplicable
In production the trace is opt-in (the hot path is allocation-free), and the admin API
exposes it as POST /sentinel-admin/authz/inspect — always against a fresh snapshot, so
you debug reality rather than the cache.
Next steps
- 005 — Multi-org & delegated admin — the cross-org rule doing real work.
- 008 — Shadow-mode migration — running this evaluator side by side with the one you’re replacing.
- The authorization engine reference.