Skip to content

ADR-0033 Authorization

Company standard for what a signed-in user may do — Better Auth access control for roles and permissions, checked on the server at every entry point, with row scope in the query.

Decision
Better Auth access controlStore & protect
Status
Accepted
Date
Scope
Company-wide default for all new apps

ADR-0009 settles who the user is. It does not settle what they may do, so every product invents its own role checks.

  • Two kinds of entry point: server functions and API routes (ADR-0036). A check that exists only in the UI is not a check
  • Two kinds of product: single-tenant (one company’s back office) and multi-tenant (customers in organizations)
  • Two questions per request: may this user do this kind of thing, and is this particular row theirs to touch. The second is answered in Postgres (ADR-0008)
  • Better Auth already ships access control in its admin and organization plugins, and plugins are opt-in per product

What is open: where roles and permissions are defined, where they are checked, and how rows are scoped.

Pros

  • Already installed; roles live in our own tables (user.role, or the organization membership)
  • One typed statement of resources and actions; roles are built from it, and the server and the client share the definition
  • A server API to check a permission, and a synchronous client check for showing or hiding controls

Cons

  • Role-based: sharing one record with one person is not modeled
  • Two plugins with two check calls (userHasPermission, hasPermission)
  • Roles are code: changing one is a deploy

Pros: rules can depend on attributes of the record; runs on server and client. Cons: a second definition of roles beside Better Auth’s, and our own glue to build abilities per request.

Pros: the database enforces scope even when a query forgets its where. Cons: every request must set its identity inside a transaction on a pooled connection; policies live in migrations, away from the code that depends on them; harder to test and to debug.

4. An authorization service (OpenFGA, Cerbos)

Section titled “4. An authorization service (OpenFGA, Cerbos)”

Pros: fine-grained, relationship-based sharing; one model across products. Cons: another service to run and a network call per check — far more than role checks need.

Better Auth access control defines roles and permissions. The server checks them at every entry point, and the query scopes the rows.

Concretely:

  • One permissions module, src/lib/permissions.ts: a statement of resources and actions (as const), passed to createAccessControl from better-auth/plugins/access, and the roles built from it. It is client-safe, so it sits in lib/ (ADR-0026); the server plugin in server/auth.ts and the client plugin import the same module
  • One plugin per product: admin for single-tenant apps (the role is on the user), organization for multi-tenant apps (the role is on the membership). Its tables are generated into our Drizzle schema and migrated by Drizzle Kit, as in ADR-0009
  • Code asks about permissions, never roles: “may this user update a project”, not “is this user an admin”. Adding or changing a role then touches one file
  • Every server function and API route handler authorizes before it calls the feature function (ADR-0027), through one helper that wraps auth.api.userHasPermission or auth.api.hasPermission and refuses the request
  • Row scope is a where. The permission says “may update projects”; the query says “this project, in this organization”. Feature functions receive the user or organization ID from the session and put it in the Drizzle query — never an ID the client sent
  • Responses: 401 when there is no session, 403 when the permission is missing, 404 when the row is outside the user’s scope, so its existence is not revealed
  • The client check is cosmetic: checkRolePermission hides or disables controls; the server still decides
  • Tests: each protected entry point has guard tests (ADR-0014) — a user without the permission is refused, and another tenant’s row is not found
  • Not the default: row-level security, a policy library, or an authorization service. A product that needs per-record sharing writes its own ADR

The deciding factors: nothing new to install, roles kept in our own tables, one typed definition shared by server and client, and checks that sit in the handlers we already keep thin. We accept that row scope depends on every query carrying its where.

Easier:

  • Roles, permissions, and their meaning are in one reviewed file per product
  • A new role is a change to that file, not a search through the code for role names
  • Authorization failures look the same on every entry point

Harder:

  • A forgotten where is a data leak the database will not stop; guard tests and review are the defense
  • The admin plugin also brings ban and impersonation endpoints — decide which roles hold them
  • A role change needs a deploy, unless a product turns on the organization plugin’s dynamic roles

Given up: attribute-based rules and database-enforced isolation. Accepted — role checks plus scoped queries cover our products, and a product that outgrows them can add either without replacing this.