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]>
This commit is contained in:
ekklesia
2026-07-26 21:54:51 -05:00
co-authored by Claude Opus 5
parent 6b6b77c105
commit 532ce3803b
12 changed files with 1373 additions and 53 deletions
@@ -0,0 +1,13 @@
CREATE TABLE "ekklesia_core_platform_owner_bootstrap" (
"id" text PRIMARY KEY NOT NULL,
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
"consumed_for_user_id" text,
CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton')
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk";
--> statement-breakpoint
INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id")
SELECT 'singleton'
WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner")
ON CONFLICT ("id") DO NOTHING;
+710
View File
@@ -0,0 +1,710 @@
{
"id": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
"prevId": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+6
View File
@@ -13,6 +13,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0002_authorization_integrity",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1784819635300,
"tag": "0001_authorization_core_tables",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785104670124,
"tag": "0002_authorization_integrity",
"breakpoints": true
}
]
}
+33 -11
View File
@@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js";
import {
ekklesiaCoreAuthorizationAudit,
ekklesiaCorePlatformOwner,
ekklesiaCorePlatformOwnerBootstrap,
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
} from "./schema.js";
export type PlatformOwnerBootstrapErrorCode =
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
@@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput {
/**
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
* composition of "check, then call the existing grant function": the single-use check, the target
* composition of "check, then call the existing grant function": the single-use claim, the target
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
* statement — so two concurrent callers can never both observe "no owner yet" and both insert.
* statement — so two concurrent callers can never both observe "not yet consumed" and both insert.
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
* re-evaluates against the now-current state.
*
* "Already consumed" is global and permanent: it is true the moment
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
* platform owner would ever be re-established after a revocation, not this command.
* **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
* existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
* treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
* user row silently reopened the one operation that establishes a platform's first administrator —
* along with the audit evidence that it had ever run. The dedicated singleton has no user foreign
* key, is never deleted by Core, and therefore survives owner revocation and user deletion alike.
*
* Consumption is deliberately tied to *success*: the sentinel is written inside the same
* transaction as the owner row and its audit entry, so a missing target user, a constraint
* violation, or any other failure rolls the sentinel back with everything else and leaves the one
* legitimate first use still available. There is no reset command, deletion path, HTTP endpoint,
* environment override, or force flag — by design, "consumed" is permanent.
*/
export async function bootstrapPlatformOwner(
db: AuthorizationDrizzleDatabase,
@@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner(
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
);
const existingOwnerRows = await tx
.select({ userId: ekklesiaCorePlatformOwner.userId })
.from(ekklesiaCorePlatformOwner)
const consumedRows = await tx
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
.from(ekklesiaCorePlatformOwnerBootstrap)
.limit(1);
if (existingOwnerRows.length > 0) {
if (consumedRows.length > 0) {
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
}
@@ -93,6 +106,15 @@ export async function bootstrapPlatformOwner(
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
}
// The sentinel is claimed inside the same transaction as everything below it. The advisory
// lock already serializes concurrent callers; the singleton primary key is the independent,
// structural backstop that makes a second row impossible even if a future caller reached
// this insert without the lock.
await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({
consumedForUserId: input.userId,
id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
});
await tx.insert(ekklesiaCorePlatformOwner).values({
grantedByActorId: BOOTSTRAP_ACTOR_ID,
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
+53 -17
View File
@@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable(
);
/**
* Append-only: no update or delete path is ever exposed by Core's authorization store. `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.
* 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",
@@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
action: text("action").notNull(),
actorType: text("actor_type").notNull(),
actorId: text("actor_id"),
subjectUserId: text("subject_user_id")
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
subjectUserId: text("subject_user_id").notNull(),
permissionId: text("permission_id"),
scope: text("scope"),
grantId: uuid("grant_id"),
@@ -110,6 +117,40 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
],
);
/**
* 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],
@@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations(
}),
);
export const ekklesiaCoreAuthorizationAuditRelations = relations(
ekklesiaCoreAuthorizationAudit,
({ one }) => ({
subject: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
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;
@@ -3,12 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseEkklesiaUserId } from "@ekklesia/contracts";
import { createUserActor, parseEkklesiaUserId, parsePermissionId } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
PlatformOwnerBootstrapError,
} from "../src/authorization/index.js";
import {
@@ -255,7 +256,7 @@ describe.sequential("live platform-owner bootstrap", () => {
expect(audit).toHaveLength(1);
});
it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => {
it("still refuses bootstrap after the existing owner is revoked — the durable consumed record, not the owner row, is the authority", async () => {
await safeQuery(
admin,
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
@@ -362,3 +363,328 @@ describe.sequential("live platform-owner bootstrap — concurrency", () => {
}
});
});
/**
* Pass 3: bootstrap's single-use authority and authorization security history must both survive
* user deletion, which is exactly what the pre-Pass-3 cascading foreign keys destroyed.
*
* The administrative client here is **test-only provisioning and inspection infrastructure**: it
* seeds users, performs the user deletion under test, and reads back the resulting state. It is
* never an API pool or a runtime connection, and every client and disposable database it opens is
* closed or dropped in `finally`.
*/
describe.sequential("live platform-owner bootstrap — durability across user deletion", () => {
async function consumedCount(client: Client): Promise<number> {
const rows = await safeQuery<{ count: string }>(
client,
`select count(*)::text as count from "ekklesia_core_platform_owner_bootstrap"`,
);
return Number(rows[0]?.count ?? "0");
}
it("records exactly one consumed row, one owner row, and one audit row in one transaction", async () => {
const database = await createDisposableDatabase("durable");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const ownerId = parseEkklesiaUserId("durable_owner");
const laterId = parseEkklesiaUserId("durable_later");
await seedUser(admin, ownerId, "[email protected]");
await seedUser(admin, laterId, "[email protected]");
expect(await consumedCount(admin)).toBe(0);
await bootstrapPlatformOwner(db, { userId: ownerId });
const consumed = await safeQuery<{ consumed_for_user_id: string | null; id: string }>(
admin,
`select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`,
);
expect(consumed).toHaveLength(1);
expect(consumed[0]?.id).toBe("singleton");
expect(consumed[0]?.consumed_for_user_id).toBe(ownerId);
expect(await ownerRowCount(admin)).toBe(1);
expect(
await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
),
).toHaveLength(1);
// Deleting the bootstrapped owner's user row removes the *live* owner row, exactly as
// before — but no longer the consumed sentinel or the security history describing it.
await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [ownerId]);
expect(await ownerRowCount(admin)).toBe(0);
expect(await consumedCount(admin)).toBe(1);
const survivingAudit = await safeQuery<{ action: string; subject_user_id: string }>(
admin,
`select action, subject_user_id from "ekklesia_core_authorization_audit"`,
);
expect(survivingAudit).toHaveLength(1);
expect(survivingAudit[0]).toMatchObject({
action: "platform_owner.grant",
subject_user_id: ownerId,
});
// And bootstrap is still consumed — the operation that establishes a platform's first
// administrator cannot be reopened by deleting a user.
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: laterId });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
expect(await ownerRowCount(admin)).toBe(0);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
it("retains a grant subject's create and revoke audit rows after that user is deleted", async () => {
const database = await createDisposableDatabase("audit_survives");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const subjectId = parseEkklesiaUserId("audit_subject");
const managerId = parseEkklesiaUserId("audit_manager");
await seedUser(admin, subjectId, "[email protected]");
await seedUser(admin, managerId, "[email protected]");
const store = createAuthorizationStore(db);
const grant = await store.createGrant({
actor: createUserActor(managerId),
permission: parsePermissionId("calendar.events.read"),
sourceKind: "explicit",
userId: subjectId,
});
await store.revokeGrant({ actor: createUserActor(managerId), grantId: grant.id });
const before = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`,
[subjectId],
);
expect(before.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [subjectId]);
// The live grant row still cascades with the user, as intended — the security *history*
// does not.
expect(
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [
subjectId,
]),
).toHaveLength(0);
const after = await safeQuery<{ action: string; subject_user_id: string }>(
admin,
`select action, subject_user_id from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`,
[subjectId],
);
expect(after.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
// The retained subject is a plain identifier snapshot; no profile data was ever copied here.
expect(Object.keys(after[0] as object).sort()).toEqual(["action", "subject_user_id"]);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
it("does not consume bootstrap when the transaction fails after the sentinel was claimed", async () => {
const database = await createDisposableDatabase("failed_claim");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const ownerId = parseEkklesiaUserId("failed_claim_owner");
await seedUser(admin, ownerId, "[email protected]");
// A temporary trigger makes the audit insert — the last statement of the bootstrap
// transaction, after the sentinel and owner row were already written — violate the audit
// action CHECK constraint.
await safeQuery(
admin,
`create function ekklesia_test_break_bootstrap() returns trigger as $$
begin new.action := 'not.a.real.action'; return new; end;
$$ language plpgsql`,
);
await safeQuery(
admin,
`create trigger ekklesia_test_break_bootstrap_trigger before insert on "ekklesia_core_authorization_audit"
for each row execute function ekklesia_test_break_bootstrap()`,
);
try {
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: ownerId });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_FAILED" });
// The sentinel, the owner row, and the audit row all rolled back together, so the one
// legitimate first use is still available.
expect(await consumedCount(admin)).toBe(0);
expect(await ownerRowCount(admin)).toBe(0);
} finally {
await safeQuery(
admin,
`drop trigger if exists ekklesia_test_break_bootstrap_trigger on "ekklesia_core_authorization_audit"`,
);
await safeQuery(admin, `drop function if exists ekklesia_test_break_bootstrap()`);
}
// With the failure removed, bootstrap still works — it was never burned.
await bootstrapPlatformOwner(db, { userId: ownerId });
expect(await consumedCount(admin)).toBe(1);
expect(await ownerRowCount(admin)).toBe(1);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
});
/**
* Upgrade path: an installation created before this migration must not gain a fresh first use.
* These scenarios apply only 0000/0001, plant representative pre-migration state through the
* test-only administrative client, then apply 0002 and assert the backfill's effect.
*/
describe.sequential("live Core migration 0002 — bootstrap-consumed backfill", () => {
async function applyThrough(url: string, upTo: number): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-upgrade-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
const selected = {
...aggregate,
migrations: aggregate.migrations.slice(0, upTo),
};
await applyModuleMigrations({
aggregate: selected,
database: createPostgresMigrationDatabase(url),
});
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
async function consumedRows(client: Client) {
return await safeQuery<{ consumed_for_user_id: string | null; id: string }>(
client,
`select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`,
);
}
it("backfills consumed state for an installation that already has an owner, including a revoked one", async () => {
for (const [label, revoke] of [
["active_owner", false],
["revoked_owner", true],
] as const) {
const database = await createDisposableDatabase(label);
const admin = await openAdministrativeClient(database.url);
try {
await applyThrough(database.url, 2);
const ownerId = parseEkklesiaUserId(`upgrade_owner_${label}`);
const nextId = parseEkklesiaUserId(`upgrade_next_${label}`);
await seedUser(admin, ownerId, `upgrade-${label}@example.test`);
await seedUser(admin, nextId, `upgrade-next-${label}@example.test`);
await safeQuery(
admin,
`insert into "ekklesia_core_platform_owner" (user_id, granted_by_actor_type, granted_by_actor_id)
values ($1, 'system', 'authorization-bootstrap-cli')`,
[ownerId],
);
await safeQuery(
admin,
`insert into "ekklesia_core_authorization_audit" (action, actor_type, actor_id, subject_user_id)
values ('platform_owner.grant', 'system', 'authorization-bootstrap-cli', $1)`,
[ownerId],
);
if (revoke) {
await safeQuery(
admin,
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
[ownerId],
);
}
await applyThrough(database.url, 3);
const consumed = await consumedRows(admin);
expect(consumed, label).toHaveLength(1);
expect(consumed[0]?.id).toBe("singleton");
// A backfill cannot know which of several historical owner rows the original single use
// targeted, so it deliberately records no subject rather than fabricating one.
expect(consumed[0]?.consumed_for_user_id).toBeNull();
// Every pre-existing audit row survives the migration untouched.
expect(
await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`),
).toHaveLength(1);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: nextId });
} catch (error) {
captured = error;
}
expect(captured, label).toMatchObject({
code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
});
} finally {
await close();
}
} finally {
await safeEnd(admin);
await dropDatabase(database.name);
}
}
});
it("leaves bootstrap available exactly once for an installation that never had an owner", async () => {
const database = await createDisposableDatabase("never_owned");
const admin = await openAdministrativeClient(database.url);
try {
await applyThrough(database.url, 2);
const userId = parseEkklesiaUserId("never_owned_user");
await seedUser(admin, userId, "[email protected]");
await applyThrough(database.url, 3);
// A legitimately never-bootstrapped installation must retain its one first use.
expect(await consumedRows(admin)).toHaveLength(0);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId });
expect(await consumedRows(admin)).toHaveLength(1);
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
} finally {
await close();
}
} finally {
await safeEnd(admin);
await dropDatabase(database.name);
}
});
});
@@ -171,6 +171,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
@@ -222,6 +223,7 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_grant",
"ekklesia_core_migrations",
"ekklesia_core_platform_owner",
"ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session",
"ekklesia_core_user",
"ekklesia_core_verification",