feat: add authorization bootstrap and HTTP enforcement

Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+30 -19
View File
@@ -9,6 +9,8 @@ A follow-up corrective slice hardened this transport (sanitized Better Auth logg
production-equivalent origin/CSRF protection under the test runner, bounded identity database
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
## What this is, and is not
@@ -54,6 +56,7 @@ No real values are shown here — configure per environment and never commit the
| --- | --- | --- |
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
deterministic `404 {"code":"NOT_FOUND"}`.
@@ -101,11 +104,12 @@ exist or treats an empty database as special. Baseline authenticated status is w
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
saved content, preferences) — none of those features are implemented in this PR.
Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit,
auditable operator command that grants an *existing* identity platform-owner authority — signup
order is never part of that decision. Future **relationship-derived access** (for example, a
person added to a volunteer roster automatically gaining narrow, reversible read access to that
roster) belongs to a later roster slice, not this one.
Explicit **platform-owner bootstrap** is now available as a local operator command — see
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
platform-owner authority; signup order is never part of that decision. Future
**relationship-derived access** (for example, a person added to a volunteer roster automatically
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
one.
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
@@ -204,15 +208,19 @@ abuse-control design.
## Database connection limits
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`,
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts`
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero,
negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any
pool is constructed, sanitized the same way an invalid connection string already is.
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
constructed, sanitized the same way an invalid connection string already is. Both the identity and
authorization domains read from this one pool through independent typed Drizzle views — see
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
not two.
## Graceful shutdown
@@ -221,7 +229,9 @@ executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
authorization both read from it — see
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
same function production composition uses, but independently testable with a fake `running`/`exit`
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
@@ -268,7 +278,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
## Next phase
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
that does not exist yet.
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
Dashboard Surface, which still depends on an authorization model that is only now getting started.