Files
Ekklesia/core/src/authorization/schema.ts
T
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

182 lines
8.0 KiB
TypeScript

import { relations, sql } from "drizzle-orm";
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner
* status, and an append-only audit log of every security-relevant mutation. Table and index names
* are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL
* schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are
* safe here (unlike a module referencing another module's tables) because Core owns both sides.
*/
export const ekklesiaCoreGrant = pgTable(
"ekklesia_core_grant",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
permissionId: text("permission_id").notNull(),
scope: text("scope"),
sourceKind: text("source_kind").notNull(),
sourceRef: text("source_ref"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
createdByActorType: text("created_by_actor_type").notNull(),
createdByActorId: text("created_by_actor_id"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type"),
revokedByActorId: text("revoked_by_actor_id"),
},
(table) => [
index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId),
check(
"ekklesia_core_grant_source_kind_check",
sql`${table.sourceKind} in ('explicit', 'relationship')`,
),
check(
"ekklesia_core_grant_created_actor_type_check",
sql`${table.createdByActorType} in ('user', 'system')`,
),
check(
"ekklesia_core_grant_revoked_actor_type_check",
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
),
],
);
export const ekklesiaCorePlatformOwner = pgTable(
"ekklesia_core_platform_owner",
{
userId: text("user_id")
.primaryKey()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
grantedByActorType: text("granted_by_actor_type").notNull(),
grantedByActorId: text("granted_by_actor_id"),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
revokedByActorType: text("revoked_by_actor_type"),
revokedByActorId: text("revoked_by_actor_id"),
},
(table) => [
check(
"ekklesia_core_platform_owner_granted_actor_type_check",
sql`${table.grantedByActorType} in ('user', 'system')`,
),
check(
"ekklesia_core_platform_owner_revoked_actor_type_check",
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
),
],
);
/**
* Append-only *by application convention*: Core's authorization store exposes no update or delete
* path for these rows. That is a property of this codebase, not a database guarantee — no
* PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
* anything else holding the same credentials from modifying them. `scope` intentionally rejects an
* empty (but non-null) string — defense in depth matching the application-layer
* `AuthorizationScope` contract, which never produces one — so a coding defect that slipped an
* empty scope past that validation fails the whole grant-plus-audit transaction atomically instead
* of recording a meaningless audit entry.
*
* `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to
* the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a
* user destroyed exactly the security history describing what that user had been granted and who
* granted it — the audit trail disappeared precisely when it mattered most. The value stays a
* plain, non-null identifier string; no name, email, or other profile data is ever copied here.
*/
export const ekklesiaCoreAuthorizationAudit = pgTable(
"ekklesia_core_authorization_audit",
{
id: uuid("id").primaryKey().defaultRandom(),
action: text("action").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id"),
subjectUserId: text("subject_user_id").notNull(),
permissionId: text("permission_id"),
scope: text("scope"),
grantId: uuid("grant_id"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId),
check(
"ekklesia_core_authorization_audit_action_check",
sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`,
),
check(
"ekklesia_core_authorization_audit_actor_type_check",
sql`${table.actorType} in ('user', 'system')`,
),
check(
"ekklesia_core_authorization_audit_scope_check",
sql`${table.scope} is null or length(${table.scope}) > 0`,
),
],
);
/**
* The durable, single-row record meaning "platform-owner bootstrap was successfully consumed."
*
* Bootstrap's single-use authority previously came from the mere existence of a row in
* `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped
* owner's user row silently reopened the one-time operation that establishes the platform's first
* administrator. This table exists specifically so that authority survives owner revocation and
* user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it.
*
* `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary
* key makes a second row structurally impossible rather than merely unwritten by convention —
* concurrent first attempts collide on the primary key inside their transactions.
*
* `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the
* same retained-identifier style as the audit subject and carrying no profile data. It is null for
* an installation whose consumed state was backfilled by migration, because a backfill cannot know
* which of several historical owner rows the original single use actually targeted, and guessing
* one would fabricate history.
*/
export const ekklesiaCorePlatformOwnerBootstrap = pgTable(
"ekklesia_core_platform_owner_bootstrap",
{
id: text("id").primaryKey(),
consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(),
consumedForUserId: text("consumed_for_user_id"),
},
(table) => [
check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`),
],
);
/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */
export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton";
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreGrant.userId],
references: [ekklesiaCoreUser.id],
}),
}));
export const ekklesiaCorePlatformOwnerRelations = relations(
ekklesiaCorePlatformOwner,
({ one }) => ({
user: one(ekklesiaCoreUser, {
fields: [ekklesiaCorePlatformOwner.userId],
references: [ekklesiaCoreUser.id],
}),
}),
);
// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
// its subject is a retained historical identifier that may name a user row that no longer exists,
// so modelling it as a live relation would misdescribe the data.
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
export const authorizationSchema = {
authorizationAudit: ekklesiaCoreAuthorizationAudit,
grant: ekklesiaCoreGrant,
platformOwner: ekklesiaCorePlatformOwner,
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
} as const;