Skip to content

ReBAC & the Permission Engine

Once a request is authenticated, Auther still has to decide whether it’s allowed. That job belongs to a hybrid authorization engine — relationship-based access control (ReBAC) for the structure, with attribute-based checks (ABAC) layered on top for the context-dependent rules. A one-line mental model for the split: ReBAC asks who is related to what (Carol owns invoice 789); ABAC adds a runtime rule about the specifics (but only if the amount is under $1,000).

The design is inspired by Google’s Zanzibar — the permissions system behind most of Google’s products, which models access as relationship tuples and answers a permission question by walking the relationships between subjects and entities. This chapter covers the ReBAC half — tuples, models, and the resolution engine — and links out to the ABAC layer where context-aware policies live.

The core primitive is the access tuple: a single relationship stored in the access_tuples table (src/db/rebac-schema.ts). Every grant in the system is one row of this shape:

(entityType, entityId, relation, subjectType, subjectId)

Read it as “the subject has this relation to the entity.” A handful of concrete tuples makes the model clearer than any abstract description:

entityTypeentityIdrelationsubjectTypesubjectIdMeaning
platform*adminuseralice_idAlice is a platform admin
users*vieweruserbob_idBob can view all users
oauth_clientclient_123usegroupeng_groupThe engineering group can use client_123
client_abc:invoice*editorapikeykey_456API key 456 can edit all invoices in client_abc
client_abc:invoiceinv_789ownerusercarol_idCarol owns invoice inv_789 in client_abc

A few features make this small shape expressive enough to cover the whole platform:

  • Wildcard entity IDs. An entityId of * means “all entities of this type” — the difference between “Bob can view one user” and “Bob can view all users.”
  • Subject relations follow the Zanzibar pattern: a subject like group:admins#member means “all members of the admins group,” so a grant to a group flows to its members without a tuple per person.
  • Optional Lua conditions can ride on an individual tuple, giving you tuple-level ABAC for one specific grant.
  • Entity type ID references. A tuple can carry an entityTypeId foreign key into authorization_models.id, a stable reference that survives renaming an entity type.

All reads and writes go through the TupleRepository (src/lib/repositories/tuple-repository.ts), 18 methods in total. The resolver in the rest of this chapter leans almost entirely on its lookup side — findExact to test a single tuple, and the fan-out queries findBySubject, findBySubjects, findByEntity, and findByEntityType that feed the breadth-first expansion below. The write side (createIfNotExists) and bookkeeping (countByRelation, updateEntityTypeString) round out the surface.

Tuples say who relates to what. An authorization model says what relations and permissions exist in the first place, and how they connect — it’s the schema for a slice of the access graph. Models are stored in the authorization_models table as JSON and validated by the Zod schema in src/schemas/rebac.ts.

{
"relations": {
"owner": { "union": ["admin"] },
"admin": { "union": ["editor"] },
"editor": { "union": ["viewer"] },
"viewer": []
},
"permissions": {
"read": { "relation": "viewer" },
"write": { "relation": "editor" },
"delete": { "relation": "owner" },
"refund": {
"relation": "admin",
"policyEngine": "lua",
"policy": "if context.resource.amount < 1000 then return true else return false end"
}
}
}

Four ideas carry the whole model:

  • Relations form a hierarchy through the union field. "admin": { "union": ["editor"] } means admin implies editor; since editor implies viewer, an admin transitively gets viewer too. You define the chain once and inheritance follows.
  • Permissions map a named action to a required relation. "read": { "relation": "viewer" } means anyone with the viewer relation — or any relation that implies it — can read.
  • ABAC policies attach to a permission via policyEngine: "lua" and a policy script, evaluated at runtime against context about the resource being touched. The refund permission above only allows refunds under 1000. The ABAC chapter covers how these scripts run.
  • A hierarchy flag lets a relation declare subjectParams: { hierarchy: true }, which turns on recursive subject expansion for cases like nested groups.

Models aren’t free to mutate carelessly. The AuthorizationModelService (src/lib/auth/authorization-model-service.ts) enforces dependency safety: before removing a relation, it checks that no active tuples or registration-context grants still reference it. A tempting shortcut is to just drop the relation and let dangling references sort themselves out, but that silently breaks live grants — so the service rejects the update with a descriptive error instead.

Everything above exists to answer one runtime question: can subject S do action A on entity E? PermissionService.checkPermission() in src/lib/auth/permission-service.ts is the resolver. It runs a fixed pipeline — short-circuiting on an admin bypass, expanding subjects and relations breadth-first, matching tuples, and only then consulting any ABAC policy before it allows or denies.

checkPermission() resolution
Rendering diagram…

Walking the same pipeline in words:

  • Step 0 — Admin bypass. If the subject is a user and user.role === "admin", allow immediately. Platform admins skip the rest of the resolver.
  • Step 1 — Load the model. Find the authorization model for the entity type. No model means there’s nothing to check against, so deny.
  • Step 2 — Resolve the required relation. Look up which relation the requested permission needs. If the permission isn’t defined in the model, deny.
  • Step 3 — Expand subjects (BFS). Starting from the subject, discover every group and hierarchy they belong to: user → user-groups (the legacy table) → nested groups via hierarchy tuples. The result is a list of (subjectType, subjectId) pairs to check.
  • Step 4 — Expand relations (BFS). Starting from the required relation, discover every relation that implies it. If viewer is needed, also check editor, admin, and owner along the union chains.
  • Step 5 — Check tuples. For each expanded subject crossed with each expanded relation, look for a direct match (entityType, entityId, relation, subjectType, subjectId) or a wildcard match (entityType, "*", relation, subjectType, subjectId).
  • Step 6 — Evaluate the ABAC policy (if a tuple was found). Priority runs tuple-level condition first, then permission-level policy, then no policy (allow). Policies execute via the LuaPolicyEngine with the supplied runtime context.
  • Step 7 — No tuple found, deny. The default answer is always no.

The same engine answers questions at two distinct scopes, and keeping them straight matters: one governs Auther itself, the other governs the applications that consume it.

These control who can manage the Auther platform: its users, clients, webhooks, keys, and so on. They’re defined by 9 system models in src/lib/auth/system-models.ts:

Entity TypeDescriptionRelationsKey Permissions
platformCore access levelssuper_admin > admin > membermanage_platform
usersUser managementadmin > viewerview, create, update, delete, ban, impersonate
groupsGroup managementadmin > editor > viewerview, create, update, delete, manage_members
clientsOAuth client managementadmin > viewerview, create, update, delete, manage_access
webhooksWebhook configurationeditor > viewerview, create, update, delete, test
pipelinesPipeline managementeditor > viewerview, create, update, delete, execute
api_keysAPI key managementadminview_all, revoke
keysSigning key managementadminview, rotate
sessionsSession managementadminview_all, revoke_all

These system models are hardcoded fallbacks. If no database record exists for an entity type, the system model is used instead — which keeps the platform functional before any models have been created, including on a fresh install.

These define what users can do within a specific client application. They’re custom authorization models that administrators create, with entity types prefixed by the client ID so they can’t collide across clients:

client_abc:invoice
client_abc:report
client_abc:payment

Client-scoped permissions are embedded in the JWTs Auther issues to users, which lets the consuming application enforce fine-grained access control on its own resources without calling back to Auther on every request.