ADR-0004: Authorization model and the deferred management key
Status: Accepted Date: 2026-08-07
Context
Section titled “Context”Phase 1 needed three things from authorization that the first implementation did not deliver.
Better Auth’s access control combines the verbs in a permission request with
AND. A coarse action such as { project: ["create", "update"] } therefore
silently demands both verbs, so a role granted only one of them is denied for
reasons that are invisible at the call site.
Separately, production state changes were reachable two ways. A dedicated toggle route required a production permission while a general “update environment state” route required only a generic write permission, so an editor could enable a production flag by choosing the other endpoint. The required permission has to follow the environment being mutated, not the endpoint the caller picked.
Finally, /rpc accepts only browser sessions. Management API keys exist in the
apiKey plugin but nothing authenticates with them, and building that properly
means deciding key scoping, rotation, and rate limiting.
Decision
Section titled “Decision”Per-verb actions. Every Action in packages/auth maps to exactly one
resource and exactly one verb, so the AND connector can never be ambiguous. This
splits the previous coarse verbs into project.create / project.update /
project.archive, adds an environment resource, and separates
flag.state.update (non-production) from flag.production.toggle, and adds
environment.production.set for promoting or demoting an environment’s
isProduction mark (distinct from toggling flag state in production).
Environment-derived gating. Both state-changing flag routes resolve the flag
and the target environment first, assert they belong to the same project, and
only then authorize, choosing flag.production.toggle when
environment.isProduction is set and flag.state.update otherwise. There is no
privileged endpoint, so there is no bypass.
Marking an environment as production (on create or update) requires
environment.production.set, not the flag state capability.
isProduction is a boolean column rather than a reserved value of the free-text
type column, so an environment named canary or production-eu can be
protected without constraining what teams call their environments. A partial
unique index allows at most one live production environment per project.
Principal seam. authorize() returns an AuthPrincipal plus a denial reason
(unauthenticated, no_active_organization, or forbidden) instead of a
boolean, which is what lets routes answer 403 rather than 401 to an
authenticated but under-privileged caller. The principal union already includes
an api_key variant and authorize() already accepts a projectId, neither of
which Phase 1 produces or uses.
Management key auth is deferred. Phase 1 ships the seam, not the feature.
Consequences
Section titled “Consequences”- Role changes are testable as a pure matrix with no database, and
packages/authunit tests assert the full role-to-action grid. - Adding management key authentication in Phase 2 means producing the
api_keyprincipal variant; routes, audit attribution, and error mapping already branch onprincipal.typeand need no changes. - Project-scoped permissions can land behind the existing
projectIdparameter without touching route call sites. - Until Phase 2,
/rpcis dashboard-and-CLI-with-session only. CI and clients should not assume key-based access works.
Naming layers. Routes, permissions, and audits deliberately use different vocabularies and must not be forced into one string family:
- Route = procedure —
{resource}.{camelCaseProcedure}(what the client calls), e.g.flag.updateEnvironment,apiKey.create. - Permission = capability —
{resource}.{qualifier?}.{verb}with dots only (what the principal may do), e.g.flag.state.update,flag.production.toggle,environment.production.set. One capability may guard multiple procedures; production flag-state gating is derived from the target environment, not from which endpoint was called. - Audit = past-tense event —
{resource}.{pastTense}or{resource}.{object}.{pastTense}, e.g.flag.updated,flag.environment.toggled.
Multi-word resources use camelCase on routes, permissions, audits, and resource
identifiers (apiKey.create, apiKey.created, apiKey:${id}). The Better Auth
access-control statement key remains api_key so it stays distinct from the
plugin’s own apiKey surface; Action strings abstract that away. Actor-type
enums in the database stay api_key (storage), not camelCase.
state (capability) vs environment (procedure / audit object) is intentional:
state is the flag’s per-environment configuration; environment is the target
entity.
Audit events are a compliance record, not application logs. They are written in
the same transaction as the mutation they describe, they are append-only (there
is deliberately no update type or method), and audit_event.organization_id
carries no foreign key so history survives organization deletion. Operational
troubleshooting belongs in the structured request logs, which carry the same
requestId that appears in every error response.