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:
+30
-19
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user