Skip to content

ADR-0004: Authorization model and the deferred management key







Status: Accepted Date: 2026-08-07

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.

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.

  • Role changes are testable as a pure matrix with no database, and packages/auth unit tests assert the full role-to-action grid.
  • Adding management key authentication in Phase 2 means producing the api_key principal variant; routes, audit attribution, and error mapping already branch on principal.type and need no changes.
  • Project-scoped permissions can land behind the existing projectId parameter without touching route call sites.
  • Until Phase 2, /rpc is 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.