Inkress ADRsarchitecture decisions
Overview / Auth / authz
ADR 0023

Authorization is DB-driven from a resolved Principal, with YAML as the fallback decider

AcceptedAuth / authz

Context

commerce-api authorization historically lived in a static YAML policy (priv/rbac.yaml) plus several scattered deciders. That has two hard limits: a committed file cannot express per-tenant custom roles (a merchant defining its own staff role with its own grants), and a static file drifts from what the code actually grants, with no way to reconcile or govern over-grants programmatically. We wanted authorization that is data in the database — editable per tenant, and reconcilable against a known baseline.

Flipping the decider on a live system is dangerous, so the migration also needed to be measurable before enforcement.

RequestcredentialPrincipalone identityDB grants(resource, access_level)YAML policynon-CRUD + ownershipENFORCE_DB_AUTHZ + modelled action → DB, else YAML
FigureOne resolved Principal; DB grants for CRUD, YAML for the rest.

Decision

Resolve one Principal per request and decide authorization from grants in the database, keyed by (resource, access_level) and cached, gated by ENFORCE_DB_AUTHZ; keep the YAML decider as the fallback for what the DB model does not yet cover.

  • One Principal. Each request resolves to a single normalized identity (Api.Auth.Principal: source, subject, user, org, merchant, role, scopes, system flag; the raw credential is not retained). It is the intended single tenant-context source under USE_PRINCIPAL_AUTH — a runtime env flag (reported on in prod; the committed default is off), and making the Principal the sole source is an in-flight rollout (per-credential setters still contribute today) — so authorization and tenancy (ADR 0020) converge on the same identity.
  • DB grants for CRUD, keyed by access level. The DB decider (Api.Auth.Policy) checks a cached set of {resource, access_level} grants compiled from role_permissions ⋈ permissions. Access levels exist only for the five CRUD actions, so the DB decider is used only when ENFORCE_DB_AUTHZ is on and the action is CRUD-modelled; anonymous access is a seeded public role mirroring the YAML public pseudo-role.
  • YAML remains the fallback. Non-CRUD actions (e.g. account, charge) and ownership ("is this the caller's own row?") still take the YAML decision today, even under enforcement. The DB is the source of truth where it is modelled; YAML covers the rest.
  • Shadow, then flip, then reconcile. The rollout ran the DB decision alongside the live one and logged divergences ([authz-shadow]) until they were understood; enforcement is a runtime env flag (ENFORCE_DB_AUTHZ, flipped by restart, not committed to config). A boot-time mix auth.policy_sync --apply reconciles and prunes grants to the YAML baseline (the first prod sync pruned 193 over-grants), so the DB grants have a governed parity target.

Consequences

  • Positive: per-tenant custom roles become expressible (grants are rows, not a shared file); multiple scattered deciders collapse toward one; grants are governed and drift is measurable (parity + prune).
  • Positive: the flip was safe because it was shadow-measured first and is reversible by restarting with the flag off.
  • Neutral / to revisit: authorization is currently hybrid — DB for CRUD-modelled actions, YAML for non-CRUD actions and ownership. The (resource, access_level) key models only CRUD; the stated direction is a data-driven, action-keyed catalog with ownership expressed as grant conditions (the scope/field_policy/ row_conditions ABAC columns exist and are populated for custom roles but are not yet the enforced path).
  • Neutral / to revisit: identity resolution runs several queries per request, and the enforcement flag lives in deploy env, not the repo — so "is DB authz enforced here?" is an environment fact, not a code fact.

Alternatives considered

  • Keep the static YAML/RBAC file as the sole decider: rejected — it cannot express per-tenant custom roles and drifts from reality with no programmatic reconciliation; DB grants are per-tenant and governed by policy_sync.
  • Enforce full ABAC (row conditions / field policies) immediately: not taken yet — the columns exist and are populated, but ownership and row conditions are still evaluated by the YAML path; moving them into the DB decider is the tracked direction, done incrementally rather than in one risky switch.
  • An external authorization service (e.g. OPA): not adopted — the decider is kept in-process over a cached compiled policy, avoiding a network hop and a new dependency on the request path (inferred rationale).
← 0022 Per-transaction fees are collected as an internal ledger transfer to the platform merchant 0024 Risk and KYC are a standalone decision service that money services enforce, with a split fail posture →