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: global always applies; org needs an org context; team needs the check’s resourceTeamIds to intersect the grant’s teams (or the subject’s); self needs resourceOwnerId == 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