Commit Graph
7 Commits
Author SHA1 Message Date
ekklesiaandClaude Opus 5 532ce3803b fix: preserve authorization security history
Two security records did not survive user deletion, because both hung off a
cascading foreign key to the live Better Auth user row.

Deleting a user with authorization history deleted that history. A subject with
a create and a revoke audit row had zero rows afterwards: the record of what
they had been granted, by whom, and when it was revoked disappeared at exactly
the moment it mattered most. `ekklesia_core_authorization_audit.subject_user_id`
is now a retained, non-null historical snapshot with no foreign key — a plain
identifier that may name a user row which no longer exists. No name, email, or
other profile data is copied into authorization tables. The Drizzle relation to
the user table is dropped too, because modelling a retained snapshot as a live
relation misdescribes the data. Live rows still cascade, as intended: deleting a
user removes their grants and platform-owner row, so nothing continues
authorizing a principal that no longer exists.

Platform-owner bootstrap's single use was equally fragile. "Already consumed"
meant "a row exists in ekklesia_core_platform_owner", and that table cascades
too — so deleting the bootstrapped owner's user row removed the owner row, its
audit evidence, and with them the only thing preventing the operation that
establishes a platform's first administrator from being run again. A dedicated
`ekklesia_core_platform_owner_bootstrap` singleton now carries that authority.
Its id is pinned by a check constraint to the literal 'singleton', so the
primary key makes a second row structurally impossible rather than merely
unwritten by convention; it has no user foreign key, and Core never deletes from
it. Bootstrap claims that record inside the same transaction as the owner row
and its audit entry, so consumption is tied to success: a missing target user or
any other failure rolls the sentinel back and leaves the one legitimate first
use available. The existing transaction-scoped advisory lock still serializes
concurrent first attempts to exactly one success.

The operational consequence is deliberate and permanent: deleting the last
platform owner does not reactivate bootstrap, and neither does revoking one.

Core migration 0002_authorization_integrity creates the singleton table, drops
the cascading audit-subject constraint, and backfills consumed state for any
installation that already holds a platform-owner row — active or revoked — so an
existing deployment cannot gain a fresh first use by upgrading. It records no
subject, because a backfill cannot know which of several historical owner rows
the original use targeted and guessing would fabricate history. A never-owned
installation is untouched and keeps its one first use.

One upgrade limitation is irrecoverable and is documented rather than papered
over: if an operator manually deleted the bootstrapped owner's user row before
this migration, the old cascade already erased the owner row and its audit
evidence, so nothing remains to detect. At this checkpoint no supported Better
Auth or API user-deletion path can produce that state; it requires direct
database access.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 21:54:51 -05:00
ekklesiaandClaude Opus 5 6b6b77c105 fix: enforce production transport policy
Every PostgreSQL connection this repository opened was plaintext, in every
environment: no connection site set `ssl` at all, so credentials, session
tokens, and row data crossed the network in the clear and nothing in
configuration, startup, or `pnpm doctor` said so. The public identity URL
boundary was also looser than it appeared.

`core/src/database/transport.ts` is now the one typed policy. It has exactly two
modes: `verify-full` (TLS required, certificate chain verified, and server
identity verified against the configured host — the production default) and
`disable` (the development/test default, refused outright in production). There
is deliberately no encryption-only `require` mode, no chain-only `verify-ca`,
and no `rejectUnauthorized: false` escape hatch anywhere in the configuration
surface; every loose or aliased spelling is rejected in both environments, so a
typo never quietly becomes a weaker mode. Production has no plaintext exception
at all, not even for a loopback database — a connection string cannot prove the
database is process-local.

Verification is certificate *and* hostname. The policy supplies an explicit
`checkServerIdentity` pinned to the host parsed from DATABASE_URL, because the
pinned driver sets TLS `servername` only for non-IP hosts and Node performs no
identity check when it is unset — so `rejectUnauthorized: true` alone accepts a
certificate from any host the trusted CA ever signed when connecting by IP.
Both halves are proven against a real TLS-enabled PostgreSQL, including that the
same connection without the pinned callback succeeds. Every CA, certificate, and
key is generated at test runtime and deleted afterwards.

A DATABASE_URL may no longer carry its own TLS parameters. The pinned driver
merges parsed connection-string values over explicitly passed options, so a
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl`
object; that now fails with a stable code, reported by both `pnpm doctor` and
the migration CLIs rather than as "failed unexpectedly".

One shared environment reader keeps `process.env` out of Core's reusable
constructors, and every executable that opens a connection resolves the policy
identically: the API (validated before any pool, Better Auth instance, module
runtime, or listener exists), both migration commands, the bootstrap CLI, and
Doctor's own connection. Doctor reports it as a blocking failure, never a
warning, naming only the effective mode.

Separately, the identity base URL and every trusted origin must now be exactly
one canonical origin — the WHATWG origin serialization must equal the supplied
string — so a path, trailing slash, credentials, explicit default port, or
non-canonical case is rejected rather than normalized into something that no
longer matches what a browser sends. Production's loopback rejection now
classifies the address instead of comparing against three literals, covering
`.localhost`, all of 127.0.0.0/8, 0.0.0.0, `::`, expanded and IPv4-mapped
forms. This is a configuration-shape guarantee only: it does not resolve DNS or
prove reachability, and TLS protects the connection, not the exposure.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 17:03:52 -05:00
ekklesia 510d76f7c2 docs: correct module authorization comments
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
2026-07-24 17:59:48 -05:00
ekklesia d9dd409aa3 feat: declare module permissions
Establishes the one canonical PermissionDeclaration contract
(packages/contracts/src/authorization.ts) — id/baseline/description,
strict, unknown-property-rejecting — consumed by both Core's
authorization registry and module manifests, replacing Core's
previously separate interface.

Bumps the canonical module manifest to manifestVersion 3: permissions
become complete declarations instead of bare identifier strings, with
manifest-owned namespace and duplicate-id enforcement. Version 2 and
bare string-array permissions are rejected outright; there is no
compatibility adapter.

Adds composePlatformPermissionRegistry (core/src/modules/permissions.ts),
a pure, synchronous composer that builds one platform permission
registry from Core's own declarations plus every already-discovered
module's, in deterministic order, reusing createPermissionRegistry
and rejecting any module-contributed permission in the reserved
core.* namespace. core/src/authorization remains independent of
core/src/modules; only the module integration layer depends on
authorization.

Updates the module template, module-development docs, authorization
docs, and existing manifest fixtures for manifestVersion 3. Does not
wire the combined registry into apps/api — that is pass 2.
2026-07-24 12:25:00 -05:00
ekklesia df2af0a3e0 fix: enforce registered authorization permissions
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 11s
CI / migration-smoke (pull_request) Successful in 27s
Two related fail-closed gaps let a well-formed but never-declared
permission id slip through:

- POST /api/v1/grants created explicit grants for any well-formed
  PermissionId without checking the injected permission registry,
  allowing dormant grants for invented identifiers that could become
  effective the moment those identifiers were registered later.
- createAuthorizationEvaluator validated the permission id only inside
  the restricted-baseline branch, so a malformed permission combined
  with the public or authenticated baseline could still return allowed.

Both now fail closed: the grants route checks the target permission
against the trusted registry before calling store.createGrant (sanitized
403, no store or audit write), and the evaluator validates permission
shape before any baseline is allowed to return an allowed decision,
without adding a registry dependency to the evaluator itself.

The live integration lifecycle test switched from an unregistered
calendar.events.read to the registered core.authorization.grants.manage
permission, since module permission registration does not exist yet.
2026-07-23 11:44:45 -05:00
ekklesia 1363608552 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.
2026-07-23 11:16:32 -05:00
ekklesia 72e082ff2f feat: add core authorization foundation 2026-07-23 10:29:01 -05:00