Documentation/Learn/Mental Model
In this section

Mental Model

flowchart TB
  Token["Verified JWT: sub, roles, permissions, pv, sid, cnf.fp"]
  Counter[("Permission version in Redis")]
  Invocation["Netron invocation wrapper"]
  Context["Admitted AuthContext"]
  Token --> Invocation
  Counter -->|Check permission version| Invocation
  Invocation -->|Admit request| Context
  Context --> Gate["Authorization gate"]
  Context --> RLS["Kysera row-level policies"]
  Context --> Policy["Organization policy"]
  Context --> Service["Service permission checks"]
  Gate --> Audit["Privileged-action audit"]
  RLS --> Audit
  Policy --> Audit
  Service --> Audit

Four layers. They share one identity (the JWT-resolved AuthContext), one permission matcher (permissionMatches / hasPermission in netron/auth/utils.ts), one counter (perm-v:{userId}), and one audit sink (audit_logs).

Layer 1 — Roles

A user has one or more roles. Each role names a default permission set. Roles are coarse — fewer than ten platform- wide and a handful per organisation. Tier predicates (canModerateUserWithRole) live in @yourorg/auth-utils/role-hierarchy and are the only consumer of role-priority numbers; gates that care about specific capabilities check permissions, not roles.

Layer 2 — Permissions

Strings in the canonical registry, matched by permissionMatches, baked into the JWT at mint time. The .* wildcard expands to the set of keys under its prefix (a bare prefix without the star is not a hierarchical grant — it matches only the exact key). The matcher is the single source of truth for "does this granted set authorise this required permission?" — used unchanged on both the platform and org scopes, on the server @Auth decorator, on the @kysera/rls policy ctx, and on the frontend <PermissionGuard> / usePermission().

Layer 3 — ABAC conditions

When role + permission isn't enough, a PolicyDefinition attaches an attribute test: time window, recent step-up MFA, request IP, feature flag, rate-limit. Built-in factories cover the common shapes; custom policies are plain objects registered on the PolicyEngine. Composition via {and|or|not}.

Layer 4 — RLS

@kysera/rls policies fire on every DB query and consume the SAME AuthContext that the application-side gates see. The STRICT tier (audit_logs, platform_roles, users, user_sessions, organization_roles) fails closed on a missing auth context — raw SQL without context cannot leak through. Other tables run permissive for anonymous reads.

Live propagation

Every mutation to a user's effective permission set bumps perm-v:{userId}. The invocation wrapper compares the JWT's pv snapshot against the live counter on every call; mismatch ⇒ refresh-token flow ⇒ new JWT with the updated set. The propagation window is bounded by one RPC round-trip — no re-sign-in.

Why one mental model?

Before the unification:

  • The platform-scope matcher (shared/permissions.ts) accepted only .* wildcards.

  • The org-scope matcher (dynamic-policy.service.ts) walked segment wildcards AND honoured hierarchical prefix grant.

  • Two registries (shared/permissions.ts + org-roles JSON) could disagree.

  • Three frontend copies of ADMIN_ROLES = ['admin','superadmin'] drifted.

  • audit_logs had a pass-through RLS policy.

  • The /admin route gated on a hand-coded role-set that silently excluded moderator and security tiers.

Each of those was a real bug. The architecture above closes them by making the layers share substrate — there is exactly one place to look when something authorises (or doesn't) unexpectedly.

See also