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]>
542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
type AuthorizationScope,
|
|
createUserActor,
|
|
type GrantId,
|
|
parseAuthorizationScope,
|
|
parseEkklesiaUserId,
|
|
parsePermissionId,
|
|
SYSTEM_ACTOR,
|
|
} from "@ekklesia/contracts";
|
|
import { Client, type QueryResultRow } from "pg";
|
|
import { afterAll, describe, expect, it } from "vitest";
|
|
import {
|
|
AuthorizationStoreError,
|
|
createAuthorizationDrizzleDatabase,
|
|
createAuthorizationStore,
|
|
} from "../src/authorization/index.js";
|
|
import {
|
|
applyModuleMigrations,
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
} from "../src/migrations/index.js";
|
|
|
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
|
const coreMigrationsDirectory = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
"..",
|
|
"drizzle",
|
|
);
|
|
const trackedDatabases = new Set<string>();
|
|
let databaseCounter = 0;
|
|
|
|
class IntegrationHarnessError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "IntegrationHarnessError";
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
}
|
|
}
|
|
|
|
function harnessError(message: string): IntegrationHarnessError {
|
|
return new IntegrationHarnessError(message);
|
|
}
|
|
|
|
function baseDatabaseUrl(): string {
|
|
const value = process.env[integrationDatabaseUrlEnvironment];
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw harnessError("The authorization integration database is not configured.");
|
|
}
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
|
throw new TypeError("Unsupported protocol.");
|
|
}
|
|
} catch {
|
|
throw harnessError("The authorization integration database configuration is invalid.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function quoteDatabaseName(databaseName: string): string {
|
|
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
|
throw harnessError("The generated integration database name is invalid.");
|
|
}
|
|
return `"${databaseName}"`;
|
|
}
|
|
|
|
function makeDatabaseName(label: string): string {
|
|
const safeLabel = label
|
|
.toLowerCase()
|
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
|
.replaceAll(/^_+|_+$/gu, "")
|
|
.slice(0, 14);
|
|
databaseCounter += 1;
|
|
return `ekk_authz_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
|
}
|
|
|
|
function databaseUrl(databaseName: string): string {
|
|
const parsed = new URL(baseDatabaseUrl());
|
|
parsed.pathname = `/${databaseName}`;
|
|
parsed.searchParams.delete("application_name");
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
|
const client = new Client({ connectionString });
|
|
client.on("error", () => undefined);
|
|
try {
|
|
await client.connect();
|
|
return client;
|
|
} catch {
|
|
try {
|
|
await client.end();
|
|
} catch {
|
|
// The public harness failure remains static when connection cleanup also fails.
|
|
}
|
|
throw harnessError("The authorization integration database connection failed.");
|
|
}
|
|
}
|
|
|
|
async function safeQuery<Row extends QueryResultRow>(
|
|
client: Client,
|
|
text: string,
|
|
values: unknown[] = [],
|
|
): Promise<readonly Row[]> {
|
|
try {
|
|
const result = await client.query<Row>(text, values);
|
|
return result.rows;
|
|
} catch {
|
|
throw harnessError("An integration database administration operation failed.");
|
|
}
|
|
}
|
|
|
|
async function safeEnd(client: Client): Promise<void> {
|
|
try {
|
|
await client.end();
|
|
} catch {
|
|
throw harnessError("An integration database connection could not be closed.");
|
|
}
|
|
}
|
|
|
|
async function createDatabase(databaseName: string): Promise<void> {
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function dropDatabase(databaseName: string): Promise<void> {
|
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
|
try {
|
|
await safeQuery(
|
|
admin,
|
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
|
[databaseName],
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
|
);
|
|
trackedDatabases.delete(databaseName);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
}
|
|
|
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
|
const name = makeDatabaseName(label);
|
|
await createDatabase(name);
|
|
trackedDatabases.add(name);
|
|
return { name, url: databaseUrl(name) };
|
|
}
|
|
|
|
async function applyCoreMigrations(url: string): Promise<void> {
|
|
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-authz-no-modules-"));
|
|
try {
|
|
const aggregate = await discoverAllMigrations({
|
|
coreMigrationsDirectory,
|
|
moduleDiscovery: {
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory: emptyModulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
},
|
|
});
|
|
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 {
|
|
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function seedUser(client: Client, id: string, email: string): Promise<void> {
|
|
await safeQuery(
|
|
client,
|
|
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
|
values ($1, $2, $3, false, now(), now())`,
|
|
[id, "Integration Test User", email],
|
|
);
|
|
}
|
|
|
|
afterAll(async () => {
|
|
for (const databaseName of [...trackedDatabases]) {
|
|
await dropDatabase(databaseName).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
describe.sequential("live Core authorization store", () => {
|
|
let database: { name: string; url: string };
|
|
let admin: Client;
|
|
let store: ReturnType<typeof createAuthorizationStore>;
|
|
let close: () => Promise<void>;
|
|
|
|
const userOneId = parseEkklesiaUserId("authz_user_one");
|
|
const userTwoId = parseEkklesiaUserId("authz_user_two");
|
|
const permission = parsePermissionId("calendar.events.read");
|
|
const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123");
|
|
const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject
|
|
|
|
it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => {
|
|
database = await createDisposableDatabase("fresh");
|
|
await applyCoreMigrations(database.url);
|
|
|
|
admin = await openAdministrativeClient(database.url);
|
|
const tables = await safeQuery<{ table_name: string }>(
|
|
admin,
|
|
`select table_name from information_schema.tables
|
|
where table_schema = 'public' and table_name like 'ekklesia_core_%'
|
|
order by table_name`,
|
|
);
|
|
expect(tables.map((row) => row.table_name)).toEqual([
|
|
"ekklesia_core_account",
|
|
"ekklesia_core_authorization_audit",
|
|
"ekklesia_core_grant",
|
|
"ekklesia_core_migrations",
|
|
"ekklesia_core_platform_owner",
|
|
"ekklesia_core_platform_owner_bootstrap",
|
|
"ekklesia_core_session",
|
|
"ekklesia_core_user",
|
|
"ekklesia_core_verification",
|
|
]);
|
|
|
|
await seedUser(admin, userOneId, "[email protected]");
|
|
await seedUser(admin, userTwoId, "[email protected]");
|
|
|
|
const handle = createAuthorizationDrizzleDatabase(database.url);
|
|
close = handle.close;
|
|
store = createAuthorizationStore(handle.db);
|
|
});
|
|
|
|
it("creates a grant and finds it as active for the matching user, permission, and scope", async () => {
|
|
const grant = await store.createGrant({
|
|
actor,
|
|
permission,
|
|
scope,
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
|
|
expect(grant.userId).toBe(userOneId);
|
|
expect(grant.permission).toBe(permission);
|
|
expect(grant.scope).toBe(scope);
|
|
expect(grant.sourceKind).toBe("explicit");
|
|
expect(grant.revokedAt).toBeUndefined();
|
|
|
|
const active = await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission,
|
|
scope,
|
|
userId: userOneId,
|
|
});
|
|
expect(active).toBe(true);
|
|
});
|
|
|
|
it("does not find the grant for a different user, a different permission, or a different scope", async () => {
|
|
expect(
|
|
await store.hasActiveGrant({ now: new Date(), permission, scope, userId: userTwoId }),
|
|
).toBe(false);
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: parsePermissionId("calendar.events.write"),
|
|
scope,
|
|
userId: userOneId,
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission,
|
|
scope: parseAuthorizationScope("calendar-event:456"),
|
|
userId: userOneId,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("never lets the scoped grant satisfy an unscoped request", async () => {
|
|
const active = await store.hasActiveGrant({ now: new Date(), permission, userId: userOneId });
|
|
expect(active).toBe(false);
|
|
});
|
|
|
|
it("lets an unscoped grant satisfy both unscoped and scoped requests for the same permission", async () => {
|
|
const unscopedPermission = parsePermissionId("calendar.events.list");
|
|
await store.createGrant({
|
|
actor,
|
|
permission: unscopedPermission,
|
|
sourceKind: "relationship",
|
|
sourceRef: "roster:volunteer:42",
|
|
userId: userOneId,
|
|
});
|
|
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: unscopedPermission,
|
|
userId: userOneId,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: unscopedPermission,
|
|
scope: parseAuthorizationScope("anything-at-all"),
|
|
userId: userOneId,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("records exactly one atomic audit entry for the grant it just created", async () => {
|
|
const rows = await safeQuery<{
|
|
action: string;
|
|
actor_id: string | null;
|
|
actor_type: string;
|
|
permission_id: string | null;
|
|
scope: string | null;
|
|
subject_user_id: string;
|
|
}>(
|
|
admin,
|
|
`select action, actor_id, actor_type, permission_id, scope, subject_user_id
|
|
from "ekklesia_core_authorization_audit"
|
|
where subject_user_id = $1 and permission_id = $2`,
|
|
[userOneId, permission],
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]).toMatchObject({
|
|
action: "grant.create",
|
|
actor_id: userTwoId,
|
|
actor_type: "user",
|
|
permission_id: permission,
|
|
scope,
|
|
subject_user_id: userOneId,
|
|
});
|
|
});
|
|
|
|
it("denies a grant once it expires, evaluated against a real elapsed instant", async () => {
|
|
const expiringPermission = parsePermissionId("calendar.events.export");
|
|
const past = new Date(Date.now() - 1_000);
|
|
await store.createGrant({
|
|
actor,
|
|
expiresAt: past,
|
|
permission: expiringPermission,
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
|
|
const active = await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: expiringPermission,
|
|
userId: userOneId,
|
|
});
|
|
expect(active).toBe(false);
|
|
});
|
|
|
|
it("allows a grant that has not yet expired", async () => {
|
|
const notYetExpiredPermission = parsePermissionId("calendar.events.import");
|
|
const future = new Date(Date.now() + 86_400_000);
|
|
await store.createGrant({
|
|
actor,
|
|
expiresAt: future,
|
|
permission: notYetExpiredPermission,
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
|
|
const active = await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: notYetExpiredPermission,
|
|
userId: userOneId,
|
|
});
|
|
expect(active).toBe(true);
|
|
});
|
|
|
|
const revocablePermission = parsePermissionId("calendar.events.delete");
|
|
let revocableGrantId: GrantId;
|
|
|
|
it("revokes an active grant, records the revocation audit entry, and stops it authorizing", async () => {
|
|
const grant = await store.createGrant({
|
|
actor,
|
|
permission: revocablePermission,
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
revocableGrantId = grant.id;
|
|
|
|
await store.revokeGrant({ actor, grantId: grant.id });
|
|
|
|
const active = await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: revocablePermission,
|
|
userId: userOneId,
|
|
});
|
|
expect(active).toBe(false);
|
|
|
|
const audit = await safeQuery<{ action: string }>(
|
|
admin,
|
|
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
|
|
[revocableGrantId],
|
|
);
|
|
expect(audit.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
|
|
});
|
|
|
|
it("refuses to revoke an already-revoked grant a second time", async () => {
|
|
let captured: unknown;
|
|
try {
|
|
await store.revokeGrant({ actor, grantId: revocableGrantId });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
|
});
|
|
|
|
it("rolls back the whole grant-plus-audit transaction when the audit write violates a real constraint", async () => {
|
|
const doomedPermission = parsePermissionId("calendar.events.archive");
|
|
// The audit table's scope CHECK constraint rejects a non-null empty string; the grant table
|
|
// has no such constraint. Bypassing the application-layer AuthorizationScope validation (which
|
|
// never produces an empty string) with a raw cast lets a real Postgres constraint violation
|
|
// fire on the audit insert specifically, after the grant insert within the same transaction
|
|
// would otherwise have succeeded.
|
|
let captured: unknown;
|
|
try {
|
|
await store.createGrant({
|
|
actor,
|
|
permission: doomedPermission,
|
|
scope: "" as AuthorizationScope,
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
|
|
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
|
|
|
|
const orphanedGrants = await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_grant" where permission_id = $1`,
|
|
[doomedPermission],
|
|
);
|
|
expect(orphanedGrants).toHaveLength(0);
|
|
const orphanedAudit = await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
|
[doomedPermission],
|
|
);
|
|
expect(orphanedAudit).toHaveLength(0);
|
|
});
|
|
|
|
it("is not a platform owner before ever being granted ownership", async () => {
|
|
expect(await store.isPlatformOwner(userOneId)).toBe(false);
|
|
});
|
|
|
|
it("grants, records the audit entry for, and looks up platform-owner authority", async () => {
|
|
await store.grantPlatformOwner(userOneId, actor);
|
|
|
|
expect(await store.isPlatformOwner(userOneId)).toBe(true);
|
|
expect(await store.isPlatformOwner(userTwoId)).toBe(false);
|
|
|
|
const audit = await safeQuery<{ action: string; subject_user_id: string }>(
|
|
admin,
|
|
`select action, subject_user_id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
|
|
);
|
|
expect(audit).toEqual([{ action: "platform_owner.grant", subject_user_id: userOneId }]);
|
|
});
|
|
|
|
it("revokes platform-owner authority, records the audit entry, and stops future lookups from succeeding", async () => {
|
|
await store.revokePlatformOwner(userOneId, actor);
|
|
|
|
expect(await store.isPlatformOwner(userOneId)).toBe(false);
|
|
|
|
const audit = await safeQuery<{ action: string }>(
|
|
admin,
|
|
`select action from "ekklesia_core_authorization_audit" where action = 'platform_owner.revoke'`,
|
|
);
|
|
expect(audit).toHaveLength(1);
|
|
});
|
|
|
|
it("refuses to revoke platform-owner authority a user never held (or already lost)", async () => {
|
|
let captured: unknown;
|
|
try {
|
|
await store.revokePlatformOwner(userTwoId, actor);
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND" });
|
|
});
|
|
|
|
it("re-grants platform-owner authority after a revocation (upsert), reactivating rather than duplicating the row", async () => {
|
|
await store.grantPlatformOwner(userOneId, SYSTEM_ACTOR);
|
|
expect(await store.isPlatformOwner(userOneId)).toBe(true);
|
|
|
|
const rows = await safeQuery(
|
|
admin,
|
|
`select user_id from "ekklesia_core_platform_owner" where user_id = $1`,
|
|
[userOneId],
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
});
|
|
|
|
it("shuts down cleanly", async () => {
|
|
await close();
|
|
await safeEnd(admin);
|
|
await dropDatabase(database.name);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live Core authorization store — no automatic migration", () => {
|
|
it("creates no authorization tables merely by constructing the database, the store, or an evaluator against an unmigrated database", async () => {
|
|
const database = await createDisposableDatabase("unmigrated");
|
|
try {
|
|
const { db, close } = createAuthorizationDrizzleDatabase(database.url);
|
|
const store = createAuthorizationStore(db);
|
|
expect(store).toBeDefined();
|
|
await close();
|
|
|
|
const admin = await openAdministrativeClient(database.url);
|
|
try {
|
|
const tables = await safeQuery(
|
|
admin,
|
|
`select table_name from information_schema.tables where table_schema = 'public'`,
|
|
);
|
|
expect(tables).toHaveLength(0);
|
|
} finally {
|
|
await safeEnd(admin);
|
|
}
|
|
} finally {
|
|
await dropDatabase(database.name);
|
|
}
|
|
});
|
|
});
|