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]>
138 lines
6.2 KiB
TypeScript
138 lines
6.2 KiB
TypeScript
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,
|
|
ekklesiaCorePlatformOwnerBootstrap,
|
|
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
|
|
} from "./schema.js";
|
|
|
|
export type PlatformOwnerBootstrapErrorCode =
|
|
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
|
| "PLATFORM_OWNER_BOOTSTRAP_FAILED"
|
|
| "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND";
|
|
|
|
const bootstrapErrorMessages: Readonly<Record<PlatformOwnerBootstrapErrorCode, string>> =
|
|
Object.freeze({
|
|
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED:
|
|
"Platform-owner bootstrap has already been used and cannot be used again.",
|
|
PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.",
|
|
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.",
|
|
});
|
|
|
|
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
|
export class PlatformOwnerBootstrapError extends Error {
|
|
readonly code: PlatformOwnerBootstrapErrorCode;
|
|
|
|
constructor(code: PlatformOwnerBootstrapErrorCode) {
|
|
super(bootstrapErrorMessages[code]);
|
|
this.name = "PlatformOwnerBootstrapError";
|
|
this.code = code;
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
Object.freeze(this);
|
|
}
|
|
}
|
|
|
|
function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError {
|
|
return error instanceof PlatformOwnerBootstrapError;
|
|
}
|
|
|
|
/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an
|
|
* ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an
|
|
* auditor can tell this owner grant came specifically from the bootstrap command. Never a real
|
|
* user id and never derived from caller input. */
|
|
const BOOTSTRAP_ACTOR_TYPE = "system";
|
|
const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli";
|
|
|
|
// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s
|
|
// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated
|
|
// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically
|
|
// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway.
|
|
const bootstrapLockNamespace = 1_500_000_001;
|
|
const bootstrapLockKey = 1_500_000_002;
|
|
|
|
export interface BootstrapPlatformOwnerInput {
|
|
readonly userId: EkklesiaUserId;
|
|
}
|
|
|
|
/**
|
|
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
|
* 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 "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.
|
|
*
|
|
* **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,
|
|
input: BootstrapPlatformOwnerInput,
|
|
): Promise<void> {
|
|
try {
|
|
await db.transaction(async (tx) => {
|
|
await tx.execute(
|
|
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
|
);
|
|
|
|
const consumedRows = await tx
|
|
.select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
|
|
.from(ekklesiaCorePlatformOwnerBootstrap)
|
|
.limit(1);
|
|
if (consumedRows.length > 0) {
|
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
|
}
|
|
|
|
const targetUserRows = await tx
|
|
.select({ id: ekklesiaCoreUser.id })
|
|
.from(ekklesiaCoreUser)
|
|
.where(eq(ekklesiaCoreUser.id, input.userId))
|
|
.limit(1);
|
|
if (targetUserRows.length === 0) {
|
|
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,
|
|
userId: input.userId,
|
|
});
|
|
|
|
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
|
action: "platform_owner.grant",
|
|
actorId: BOOTSTRAP_ACTOR_ID,
|
|
actorType: BOOTSTRAP_ACTOR_TYPE,
|
|
subjectUserId: input.userId,
|
|
});
|
|
});
|
|
} catch (error) {
|
|
if (isPlatformOwnerBootstrapError(error)) {
|
|
throw error;
|
|
}
|
|
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
|
|
}
|
|
}
|