|
|
|
@@ -0,0 +1,209 @@
|
|
|
|
|
# 0013: Core authorization, permission registry, and platform-owner bootstrap
|
|
|
|
|
|
|
|
|
|
- Status: Accepted
|
|
|
|
|
- Date: 2026-07-23
|
|
|
|
|
|
|
|
|
|
## Context
|
|
|
|
|
|
|
|
|
|
ADR 0010 and ADR 0011 established Core-owned identity and its HTTP transport but explicitly left
|
|
|
|
|
authorization out of scope: every account received identical baseline authenticated status, and
|
|
|
|
|
signup order never granted elevation. `docs/architecture/platform-architecture.md` names
|
|
|
|
|
permissions as a Core concern (ADR 0001), not a feature module or a surface concern, alongside
|
|
|
|
|
identity, database connectivity, and module lifecycle.
|
|
|
|
|
|
|
|
|
|
Phase 4C adds that missing piece in two slices on this branch. The first slice
|
|
|
|
|
(`feat: add core authorization foundation`) established the schema, evaluator, store, and
|
|
|
|
|
contracts. The second (`feat: add authorization bootstrap and HTTP enforcement`) added an explicit
|
|
|
|
|
platform-owner bootstrap operation, a permission registry, and HTTP enforcement wired into
|
|
|
|
|
`apps/api`. This ADR records the durable architectural decisions both slices established, since
|
|
|
|
|
together they fix a shape future changes must not casually violate: what a permission is, who can
|
|
|
|
|
grant one, how a platform owner is established, and how any of this reaches HTTP.
|
|
|
|
|
|
|
|
|
|
## Decision
|
|
|
|
|
|
|
|
|
|
### Core owns authorization independently of Hono, Surfaces, and modules
|
|
|
|
|
|
|
|
|
|
Authorization is a Core platform concern, exactly like identity. The schema
|
|
|
|
|
(`core/src/authorization/schema.ts`), evaluator (`core/src/authorization/evaluator.ts`), store
|
|
|
|
|
(`core/src/authorization/store.ts`), permission registry (`core/src/authorization/permissions.ts`),
|
|
|
|
|
and bootstrap operation (`core/src/authorization/bootstrap.ts`) import nothing from `hono`,
|
|
|
|
|
`apps/api`, or any module, and are independently testable without an HTTP transport. `apps/api`
|
|
|
|
|
consumes Core's authorization surface the same way it already consumes identity: through explicit,
|
|
|
|
|
injected composition (`apps/api/src/authorization-middleware.ts`,
|
|
|
|
|
`apps/api/src/grants-routes.ts`), never the reverse. Modules cannot import Core at all under
|
|
|
|
|
`scripts/check-boundaries.ts`, so this holds structurally, not just by convention.
|
|
|
|
|
|
|
|
|
|
### Permission IDs are exact, registered, and deny by default
|
|
|
|
|
|
|
|
|
|
A `PermissionId` (`@ekklesia/contracts`) is a validated, namespaced string
|
|
|
|
|
(`core.authorization.grants.manage`), but being well-formed is not the same as being *registered*.
|
|
|
|
|
`PermissionRegistry` (`core/src/authorization/permissions.ts`) is the trusted, immutable,
|
|
|
|
|
composition-time-built source of which permissions actually exist and what baseline each requires.
|
|
|
|
|
Matching is always exact string equality — there is no wildcard, prefix, or namespace-inheritance
|
|
|
|
|
matching anywhere in the evaluator or the store; a permission `core.authorization.grants.manage`
|
|
|
|
|
never implies or is implied by `core.authorization.grants.*` or `core.authorization.*`. An unknown
|
|
|
|
|
or malformed permission id is treated identically to a denied decision, never a distinct "this
|
|
|
|
|
would work if declared" response — deny-by-default holds even for permissions nobody has declared
|
|
|
|
|
yet.
|
|
|
|
|
|
|
|
|
|
### Permissions declare one trusted baseline: public, authenticated, or restricted
|
|
|
|
|
|
|
|
|
|
Every registered permission declares exactly one `PermissionBaseline` at registration time. The
|
|
|
|
|
evaluator (`core/src/authorization/evaluator.ts`) still accepts a baseline as part of its own
|
|
|
|
|
request shape — that is pass 1's evaluator contract, unchanged — but the only caller allowed to
|
|
|
|
|
supply it in this repository is Core's own trusted registry lookup
|
|
|
|
|
(`requirePermission` in `apps/api/src/authorization-middleware.ts`); no HTTP route reads a baseline
|
|
|
|
|
from a request header, query parameter, or body. `public` always allows; `authenticated` requires a
|
|
|
|
|
non-anonymous principal and nothing else; `restricted` requires an authenticated principal *and*
|
|
|
|
|
either platform-owner authority or an active matching grant — the baseline alone never satisfies a
|
|
|
|
|
restricted check.
|
|
|
|
|
|
|
|
|
|
### Unknown permissions and client-supplied baselines fail closed
|
|
|
|
|
|
|
|
|
|
Two independent fail-closed layers exist on purpose, not one relied on twice. The registry itself
|
|
|
|
|
(`PermissionRegistry.get`/`.has`) returns `undefined`/`false` for anything malformed or
|
|
|
|
|
unregistered. `requirePermission` treats a registry miss as an immediate sanitized `403`, before
|
|
|
|
|
principal resolution or evaluation ever runs. Separately, `createAuthorizationEvaluator` itself
|
|
|
|
|
denies (without throwing) any malformed or unrecognized `permission`/`baseline`/`scope` it
|
|
|
|
|
receives, so a future caller that reaches the evaluator directly — bypassing the registry, for
|
|
|
|
|
example a different transport Core does not yet have — inherits the same deny-by-default guarantee
|
|
|
|
|
rather than depending on the registry check having already run.
|
|
|
|
|
|
|
|
|
|
### Explicit and relationship-derived grants share persistence but have distinct source kinds
|
|
|
|
|
|
|
|
|
|
`ekklesia_core_grant` is one table for both explicit grants (an administrator naming a subject
|
|
|
|
|
user, permission, and optional scope directly) and relationship-derived grants (a future module
|
|
|
|
|
operation establishing access automatically from a relationship it owns, for example a roster
|
|
|
|
|
assignment). A `source_kind` column, constrained to exactly `'explicit'` or `'relationship'`,
|
|
|
|
|
distinguishes them at the row level; nothing else about how a grant is stored or evaluated differs
|
|
|
|
|
by source kind. This lets both kinds share one evaluator code path and one audit trail while still
|
|
|
|
|
letting HTTP enforcement treat them very differently (see below).
|
|
|
|
|
|
|
|
|
|
### Unscoped grants satisfy scoped or unscoped checks; scoped grants satisfy only the exact matching scope
|
|
|
|
|
|
|
|
|
|
`AuthorizationStore.hasActiveGrant` (`core/src/authorization/store.ts`) matches a grant whose
|
|
|
|
|
`scope` column is `NULL` against *any* check, scoped or unscoped, but matches a grant with a
|
|
|
|
|
non-null `scope` only against a check requesting that exact scope string. An unscoped grant is
|
|
|
|
|
strictly broader than any scoped grant of the same permission; a scoped grant never leaks into an
|
|
|
|
|
unscoped check for the same permission. There is no partial, prefix, or hierarchical scope
|
|
|
|
|
matching.
|
|
|
|
|
|
|
|
|
|
### Revoked and expired grants never authorize
|
|
|
|
|
|
|
|
|
|
`hasActiveGrant`'s query requires `revoked_at IS NULL` and either no `expires_at` or
|
|
|
|
|
`expires_at` in the future, evaluated against an injectable clock (`now`) rather than each caller's
|
|
|
|
|
own wall-clock read. Revocation is soft: `AuthorizationStore.revokeGrant` sets `revoked_at` and the
|
|
|
|
|
revoking actor rather than deleting the row, so the audit trail and the grant's own history remain
|
|
|
|
|
queryable after revocation, but a revoked row can never again satisfy an authorization check.
|
|
|
|
|
|
|
|
|
|
### Platform-owner authority is represented separately from grants and is not a wildcard permission
|
|
|
|
|
|
|
|
|
|
`ekklesia_core_platform_owner` is its own table, not a synthetic all-permissions grant row.
|
|
|
|
|
`createAuthorizationEvaluator` checks `platformOwners.isPlatformOwner(userId)` as an independent
|
|
|
|
|
`OR` alongside `hasActiveGrant`, so owner status bypasses the grant/scope machinery entirely rather
|
|
|
|
|
than being implemented as a grant with a special scope or permission value that other code could
|
|
|
|
|
accidentally match against. This keeps "is a platform owner" and "has this specific permission"
|
|
|
|
|
structurally distinct, so a future owner-management feature can reason about the one table without
|
|
|
|
|
touching grant semantics.
|
|
|
|
|
|
|
|
|
|
### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically
|
|
|
|
|
|
|
|
|
|
There is no signup-order or first-user inference anywhere in this repository. The only way to
|
|
|
|
|
establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id <id>
|
|
|
|
|
--confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`,
|
|
|
|
|
`core/src/authorization/bootstrap.ts`), never an HTTP endpoint. "Bootstrap has already been
|
|
|
|
|
consumed" is defined as *any row ever created* in `ekklesia_core_platform_owner`, for any user,
|
|
|
|
|
including one whose `revoked_at` is set — there is no path in this slice that re-opens bootstrap
|
|
|
|
|
after a revocation; a later, separate owner-management feature (not part of this slice) is how a
|
|
|
|
|
platform owner would ever be re-established. The single-use check, the target user's existence
|
|
|
|
|
check, the owner-row insert, and its audit-row insert are one PostgreSQL transaction, serialized by
|
|
|
|
|
a transaction-scoped advisory lock (`pg_advisory_xact_lock`, a key pair distinct from the migration
|
|
|
|
|
runner's own lock) acquired as the transaction's first statement, so two concurrent bootstrap
|
|
|
|
|
attempts can never both observe "no owner yet" — exactly one succeeds, proven against real
|
|
|
|
|
PostgreSQL. Any failure path (nonexistent user, lost race, audit failure) rolls back the entire
|
|
|
|
|
transaction, leaving no partial owner or bootstrap state.
|
|
|
|
|
|
|
|
|
|
### Identity and Authorization use typed Drizzle views over one physical PostgreSQL pool in apps/api
|
|
|
|
|
|
|
|
|
|
`apps/api/src/database.ts`'s `createApiDatabase` constructs exactly one validated `pg.Pool`
|
|
|
|
|
(`core/src/database/pool.ts`) and wraps it with two independent typed Drizzle views
|
|
|
|
|
(`createIdentityDrizzleView`, `createAuthorizationDrizzleView`) rather than owning two pools. This
|
|
|
|
|
is additive: `createIdentityDrizzleDatabase` and `createAuthorizationDrizzleDatabase` (each owning
|
|
|
|
|
a private pool) remain available and unaffected for callers — the bootstrap CLI included — that
|
|
|
|
|
only need one domain and have no reason to share a connection. `apps/api` closes the one shared
|
|
|
|
|
pool exactly once, on graceful shutdown and on any startup failure after the pool was already
|
|
|
|
|
constructed.
|
|
|
|
|
|
|
|
|
|
### Cookie-authenticated authorization mutations require an exact trusted Origin
|
|
|
|
|
|
|
|
|
|
`requireTrustedOrigin` (`apps/api/src/authorization-middleware.ts`) requires an exact match against
|
|
|
|
|
the same trusted-origin set already used for CORS and Better Auth's own checks (ADR 0011, ADR
|
|
|
|
|
0012) on every grant-mutation route. This is deliberately a server-side check distinct from CORS:
|
|
|
|
|
CORS response headers only stop a browser from *reading* a cross-origin response, not from
|
|
|
|
|
*sending* the state-changing request in the first place. A missing or untrusted origin is rejected
|
|
|
|
|
before the permission check or any state change. Non-browser, non-cookie automation (service
|
|
|
|
|
accounts/API tokens) has no way to satisfy this check and is explicitly deferred, not silently
|
|
|
|
|
exempted.
|
|
|
|
|
|
|
|
|
|
### Generic HTTP grant management handles only explicit grants
|
|
|
|
|
|
|
|
|
|
`POST /api/v1/grants` always sets `source_kind` to `'explicit'` server-side — the request body has
|
|
|
|
|
no field that can influence it — and `POST /api/v1/grants/:grantId/revoke` always filters its
|
|
|
|
|
mutation to `source_kind = 'explicit'`. A relationship-derived grant's id, presented to the revoke
|
|
|
|
|
route, matches zero rows and returns the same `404` a nonexistent grant id would, indistinguishably
|
|
|
|
|
from the caller's perspective. The generic HTTP API also never accepts `actor`, `sourceKind`,
|
|
|
|
|
`sourceRef`, any audit field, a grant id, `createdAt`, or `revokedAt` in a mutation body: the
|
|
|
|
|
allowlist rejects the entire request if any other key is present, so a well-formed-looking request
|
|
|
|
|
cannot smuggle a server-derived field past validation.
|
|
|
|
|
|
|
|
|
|
### Relationship-derived grants may be created or revoked only by the trusted module operation owning that relationship
|
|
|
|
|
|
|
|
|
|
No code path in this repository creates a `source_kind = 'relationship'` grant yet — there is no
|
|
|
|
|
roster or similar module operation in this slice — but the architectural boundary is fixed here:
|
|
|
|
|
when that operation exists, it is the only trusted caller for that grant's lifecycle, exercised
|
|
|
|
|
through `AuthorizationStore` directly (as pass 2's own integration tests do to seed one), never
|
|
|
|
|
through the generic `/api/v1/grants` HTTP surface. Nothing about this ADR assumes what that future
|
|
|
|
|
module operation looks like — only that it does not go through the explicit-grant API.
|
|
|
|
|
|
|
|
|
|
### Deferred
|
|
|
|
|
|
|
|
|
|
Roles, groups, teams, and tenants; an owner-management HTTP endpoint (revoking or re-granting
|
|
|
|
|
platform-owner status over HTTP); service accounts and API tokens for non-browser automation; and
|
|
|
|
|
module permission discovery/registration (a mechanism for a module to contribute its own
|
|
|
|
|
declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
|
|
|
|
|
remain later work.
|
|
|
|
|
|
|
|
|
|
## Alternatives
|
|
|
|
|
|
|
|
|
|
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
|
|
|
|
|
reserved scope value meaning "everything") was rejected: it would make "is this row an owner
|
|
|
|
|
grant or a real permission grant" a runtime distinction every future grant-handling code path
|
|
|
|
|
would need to remember to check, rather than a structural one enforced by table shape.
|
|
|
|
|
- Implementing bootstrap as "check for an existing owner, then call the same `grantPlatformOwner`
|
|
|
|
|
function grants already use" was rejected: composing two separately-transacted operations leaves
|
|
|
|
|
a window between the check and the grant where a concurrent caller could observe the same "no
|
|
|
|
|
owner yet" state, which is exactly the race a single-use bootstrap must not allow. A dedicated,
|
|
|
|
|
single-transaction operation closes that window structurally instead of relying on discipline at
|
|
|
|
|
every call site.
|
|
|
|
|
- Allowing the generic explicit-grant HTTP API to also manage relationship-derived grants (with an
|
|
|
|
|
optional `sourceKind` field in the request body) was rejected: it would let any caller holding
|
|
|
|
|
`core.authorization.grants.manage` silently unwind access a module relationship established for
|
|
|
|
|
reasons the generic API has no way to evaluate — for example, revoking a roster-derived grant
|
|
|
|
|
without the roster module ever knowing its own invariant was violated.
|
|
|
|
|
- A second physical PostgreSQL pool dedicated to authorization in `apps/api` (mirroring identity's
|
|
|
|
|
own private-pool factory) was rejected in favor of one shared pool: `apps/api` is one process
|
|
|
|
|
with one connection budget, and two independently-sized pools would double configured connection
|
|
|
|
|
limits for no isolation benefit within a single process.
|
|
|
|
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
|
|
|
|
Every HTTP route that needs authorization now goes through one reusable middleware
|
|
|
|
|
(`requirePermission`) backed by one trusted registry, rather than each route independently deciding
|
|
|
|
|
how to check a permission. Establishing a platform owner is a deliberate, auditable, one-time
|
|
|
|
|
operator action with no ambiguity about who holds it or how it was granted. Future work extending
|
|
|
|
|
authorization — module-declared permissions, relationship-derived grants, roles — has a fixed shape
|
|
|
|
|
to extend rather than a green field: new permissions register through the same
|
|
|
|
|
`createPermissionRegistry` constructor, new grant sources use the existing `source_kind` column
|
|
|
|
|
and audit table, and any new HTTP surface reuses `requirePermission`/`requireTrustedOrigin` rather
|
|
|
|
|
than reimplementing enforcement. No identity behavior, route, or response shape from ADR 0010,
|
|
|
|
|
0011, or 0012 changed.
|