Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
1301 lines
48 KiB
TypeScript
1301 lines
48 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,
|
|
parseGrantId,
|
|
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",
|
|
"core:0003_dashboard_navigation_tables",
|
|
"core:0004_module_lifecycle_state",
|
|
"core:0005_module_configuration",
|
|
"core:0006_device_identity",
|
|
"core:0007_verified_bootstrap_owners",
|
|
]);
|
|
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 expected Core 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`,
|
|
);
|
|
// The complete Core schema after every migration, asserted exactly so a new table is a
|
|
// deliberate change here rather than an unnoticed one.
|
|
expect(tables.map((row) => row.table_name)).toEqual([
|
|
"ekklesia_core_account",
|
|
"ekklesia_core_authorization_audit",
|
|
"ekklesia_core_dashboard_group",
|
|
"ekklesia_core_dashboard_placement",
|
|
// Device identity is Core's, and deliberately its own tables rather than rows smuggled into
|
|
// the user or grant tables (ADR 0036).
|
|
"ekklesia_core_device",
|
|
"ekklesia_core_device_pairing_request",
|
|
"ekklesia_core_device_permission",
|
|
"ekklesia_core_grant",
|
|
"ekklesia_core_migrations",
|
|
"ekklesia_core_module_configuration",
|
|
"ekklesia_core_module_state",
|
|
"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("refuses to create a grant that is already expired on arrival", async () => {
|
|
// The store no longer accepts a past expiration at all: such a grant is dead the moment it is
|
|
// written, which is never what a caller meant. This is validated before any transaction opens.
|
|
let captured: unknown;
|
|
try {
|
|
await store.createGrant({
|
|
actor,
|
|
expiresAt: new Date(Date.now() - 1_000),
|
|
permission: parsePermissionId("calendar.events.export"),
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_EXPIRATION_INVALID" });
|
|
expect(
|
|
await safeQuery(admin, `select id from "ekklesia_core_grant" where permission_id = $1`, [
|
|
"calendar.events.export",
|
|
]),
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
it("denies a grant once its expiry has passed, evaluated against a real elapsed instant", async () => {
|
|
const expiringPermission = parsePermissionId("calendar.events.expiring");
|
|
const expiresAt = new Date(Date.now() + 60_000);
|
|
await store.createGrant({
|
|
actor,
|
|
expiresAt,
|
|
permission: expiringPermission,
|
|
sourceKind: "explicit",
|
|
userId: userOneId,
|
|
});
|
|
|
|
// Deterministic rather than sleeping: the read port evaluates expiration against the instant
|
|
// it is given, so advancing that instant past the stored expiry is a real elapsed-time check.
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(expiresAt.getTime() - 1_000),
|
|
permission: expiringPermission,
|
|
userId: userOneId,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(expiresAt.getTime() + 1_000),
|
|
permission: expiringPermission,
|
|
userId: userOneId,
|
|
}),
|
|
).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);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Pass 3 grant-integrity scenarios, all against real PostgreSQL because every property under test
|
|
* is a database-concurrency or database-constraint property that a fake cannot establish.
|
|
*
|
|
* The administrative client each scenario uses (`openAdministrativeClient`) is **test-only
|
|
* provisioning and inspection infrastructure**: it seeds users, plants legacy duplicate rows that
|
|
* the production store would now refuse to create, and reads back the resulting state. It is never
|
|
* an API pool or a runtime connection, and it is closed in `afterAll` along with its disposable
|
|
* database.
|
|
*/
|
|
describe.sequential("live Core authorization store — grant equivalence and revocation", () => {
|
|
let database: { name: string; url: string };
|
|
let admin: Client;
|
|
let store: ReturnType<typeof createAuthorizationStore>;
|
|
let close: () => Promise<void>;
|
|
|
|
const subject = parseEkklesiaUserId("equiv_subject");
|
|
const other = parseEkklesiaUserId("equiv_other");
|
|
const actor = createUserActor("equiv_other");
|
|
const permission = parsePermissionId("calendar.events.read");
|
|
|
|
async function effectiveCount(userId: string, permissionId: string): Promise<number> {
|
|
const rows = await safeQuery<{ n: string }>(
|
|
admin,
|
|
`select count(*)::int as n from "ekklesia_core_grant"
|
|
where user_id = $1 and permission_id = $2 and revoked_at is null
|
|
and (expires_at is null or expires_at > now())`,
|
|
[userId, permissionId],
|
|
);
|
|
return Number(rows[0]?.n ?? 0);
|
|
}
|
|
|
|
it("prepares a disposable database with the three Core migrations applied", async () => {
|
|
database = await createDisposableDatabase("equivalence");
|
|
await applyCoreMigrations(database.url);
|
|
admin = await openAdministrativeClient(database.url);
|
|
await seedUser(admin, subject, "[email protected]");
|
|
await seedUser(admin, other, "[email protected]");
|
|
const handle = createAuthorizationDrizzleDatabase(database.url);
|
|
close = handle.close;
|
|
store = createAuthorizationStore(handle.db);
|
|
expect(await effectiveCount(subject, permission)).toBe(0);
|
|
});
|
|
|
|
it("creates the first unscoped explicit grant and writes exactly one create audit row", async () => {
|
|
const grant = await store.createGrant({
|
|
actor,
|
|
permission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
|
|
expect(grant.scope).toBeUndefined();
|
|
const audit = await safeQuery<{ action: string }>(
|
|
admin,
|
|
`select action from "ekklesia_core_authorization_audit" where grant_id = $1`,
|
|
[grant.id],
|
|
);
|
|
expect(audit.map((row) => row.action)).toEqual(["grant.create"]);
|
|
});
|
|
|
|
it("refuses a sequential equivalent creation, leaving no second grant and no second audit row", async () => {
|
|
const auditBefore = await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
|
|
[subject, permission],
|
|
);
|
|
|
|
let captured: unknown;
|
|
try {
|
|
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
|
|
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
|
expect(await effectiveCount(subject, permission)).toBe(1);
|
|
const auditAfter = await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
|
|
[subject, permission],
|
|
);
|
|
expect(auditAfter).toHaveLength(auditBefore.length);
|
|
});
|
|
|
|
it("a differing expiration does not make an otherwise identical issuance distinct", async () => {
|
|
let captured: unknown;
|
|
try {
|
|
await store.createGrant({
|
|
actor,
|
|
expiresAt: new Date(Date.now() + 3_600_000),
|
|
permission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
|
expect(await effectiveCount(subject, permission)).toBe(1);
|
|
});
|
|
|
|
it("a different subject, permission, scope, source kind, or source reference is not equivalent", async () => {
|
|
const distinct = [
|
|
{ permission, sourceKind: "explicit" as const, userId: other },
|
|
{
|
|
permission: parsePermissionId("calendar.events.write"),
|
|
sourceKind: "explicit" as const,
|
|
userId: subject,
|
|
},
|
|
{
|
|
permission,
|
|
scope: parseAuthorizationScope("calendar-event:1"),
|
|
sourceKind: "explicit" as const,
|
|
userId: subject,
|
|
},
|
|
{ permission, sourceKind: "relationship" as const, userId: subject },
|
|
{
|
|
permission,
|
|
sourceKind: "relationship" as const,
|
|
sourceRef: "roster:7",
|
|
userId: subject,
|
|
},
|
|
];
|
|
for (const input of distinct) {
|
|
const grant = await store.createGrant({ actor, ...input });
|
|
expect(grant.id).toBeDefined();
|
|
}
|
|
// A second issuance of one of those distinct tuples is itself a duplicate.
|
|
let captured: unknown;
|
|
try {
|
|
await store.createGrant({
|
|
actor,
|
|
permission,
|
|
scope: parseAuthorizationScope("calendar-event:1"),
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
|
});
|
|
|
|
it("serializes concurrent equivalent creations to exactly one grant and one conflict", async () => {
|
|
const concurrentPermission = parsePermissionId("calendar.events.concurrent");
|
|
// Six genuinely concurrent transactions against the same tuple. The transaction-scoped
|
|
// advisory lock is what makes this deterministic: no sleeps, no retries, no process-local
|
|
// mutex — a second API process would be serialized identically.
|
|
const results = await Promise.allSettled(
|
|
Array.from({ length: 6 }, () =>
|
|
store.createGrant({
|
|
actor,
|
|
permission: concurrentPermission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
}),
|
|
),
|
|
);
|
|
|
|
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
|
const rejected = results.filter((result) => result.status === "rejected");
|
|
expect(fulfilled).toHaveLength(1);
|
|
expect(rejected).toHaveLength(5);
|
|
for (const result of rejected) {
|
|
expect((result as PromiseRejectedResult).reason).toMatchObject({
|
|
code: "AUTHORIZATION_GRANT_ALREADY_EXISTS",
|
|
});
|
|
}
|
|
expect(await effectiveCount(subject, concurrentPermission)).toBe(1);
|
|
const audit = await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_authorization_audit"
|
|
where subject_user_id = $1 and permission_id = $2 and action = 'grant.create'`,
|
|
[subject, concurrentPermission],
|
|
);
|
|
expect(audit).toHaveLength(1);
|
|
});
|
|
|
|
it("allows a fresh issuance once the previous equivalent is revoked, and again once one expires", async () => {
|
|
const cyclePermission = parsePermissionId("calendar.events.cycle");
|
|
const first = await store.createGrant({
|
|
actor,
|
|
permission: cyclePermission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
await store.revokeGrant({ actor, grantId: first.id, sourceKind: "explicit" });
|
|
|
|
// Revoked equivalents do not block a new issuance.
|
|
const second = await store.createGrant({
|
|
actor,
|
|
expiresAt: new Date(Date.now() + 60_000),
|
|
permission: cyclePermission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
expect(second.id).not.toBe(first.id);
|
|
|
|
// Force the second grant past its expiry through the administrative client, which is the only
|
|
// deterministic way to observe "expired equivalent" without sleeping.
|
|
await safeQuery(
|
|
admin,
|
|
`update "ekklesia_core_grant" set expires_at = now() - interval '1 second' where id = $1`,
|
|
[second.id],
|
|
);
|
|
const third = await store.createGrant({
|
|
actor,
|
|
permission: cyclePermission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
expect(third.id).not.toBe(second.id);
|
|
expect(await effectiveCount(subject, cyclePermission)).toBe(1);
|
|
});
|
|
|
|
it("one revocation revokes every unrevoked equivalent, audits each by its own id, and leaves nothing authorizing", async () => {
|
|
const legacyPermission = parsePermissionId("calendar.events.legacy");
|
|
// Plant the exact state the old store could produce: several equivalent issuances, including
|
|
// one already expired but never revoked, plus two deliberately non-equivalent neighbours.
|
|
const legacyIds: string[] = [];
|
|
for (const expires of ["null", "now() + interval '1 hour'", "now() - interval '1 hour'"]) {
|
|
const rows = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, expires_at)
|
|
values ($1, $2, null, 'explicit', null, 'user', $3, ${expires}) returning id`,
|
|
[subject, legacyPermission, other],
|
|
);
|
|
legacyIds.push(rows[0]?.id as string);
|
|
}
|
|
const scopedNeighbour = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
|
values ($1, $2, 'calendar-event:9', 'explicit', null, 'user', $3) returning id`,
|
|
[subject, legacyPermission, other],
|
|
);
|
|
// Deliberately on its own permission: an unscoped relationship grant for `legacyPermission`
|
|
// would legitimately keep authorizing after this revocation (it is a different equivalence
|
|
// tuple), which would make the "nothing authorizes" assertion below meaningless rather than
|
|
// wrong. The scoped neighbour above stays on the same permission because a scoped grant never
|
|
// satisfies an unscoped check.
|
|
const relationshipNeighbour = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
|
values ($1, 'calendar.events.legacyrel', null, 'relationship', 'roster:9', 'user', $2) returning id`,
|
|
[subject, other],
|
|
);
|
|
expect(await effectiveCount(subject, legacyPermission)).toBe(3);
|
|
|
|
await store.revokeGrant({
|
|
actor,
|
|
grantId: parseGrantId(legacyIds[0] as string),
|
|
sourceKind: "explicit",
|
|
});
|
|
|
|
const revoked = await safeQuery<{ id: string; revoked_at: Date | null }>(
|
|
admin,
|
|
`select id, revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
|
[legacyIds],
|
|
);
|
|
// Every unrevoked equivalent, including the expired-but-unrevoked one, is now revoked.
|
|
expect(revoked.every((row) => row.revoked_at !== null)).toBe(true);
|
|
|
|
const auditRows = await safeQuery<{ grant_id: string }>(
|
|
admin,
|
|
`select grant_id from "ekklesia_core_authorization_audit"
|
|
where action = 'grant.revoke' and grant_id = any($1::uuid[])`,
|
|
[legacyIds],
|
|
);
|
|
// One audit row per grant actually revoked, each carrying that row's own id.
|
|
expect(auditRows).toHaveLength(legacyIds.length);
|
|
expect([...new Set(auditRows.map((row) => row.grant_id))].sort()).toEqual(
|
|
[...legacyIds].sort(),
|
|
);
|
|
|
|
// Differently scoped and differently sourced grants are untouched.
|
|
const untouched = await safeQuery<{ revoked_at: Date | null }>(
|
|
admin,
|
|
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
|
[[scopedNeighbour[0]?.id, relationshipNeighbour[0]?.id]],
|
|
);
|
|
expect(untouched.every((row) => row.revoked_at === null)).toBe(true);
|
|
|
|
// No equivalent issuance continues authorizing the user.
|
|
expect(
|
|
await store.hasActiveGrant({
|
|
now: new Date(),
|
|
permission: legacyPermission,
|
|
userId: subject,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("serializes a concurrent create and revoke on the same tuple without leaving an unexplained duplicate", async () => {
|
|
const racePermission = parsePermissionId("calendar.events.race");
|
|
const seed = await store.createGrant({
|
|
actor,
|
|
permission: racePermission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
|
|
const [revokeResult, createResult] = await Promise.allSettled([
|
|
store.revokeGrant({ actor, grantId: seed.id, sourceKind: "explicit" }),
|
|
store.createGrant({
|
|
actor,
|
|
permission: racePermission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
}),
|
|
]);
|
|
|
|
// Whichever order the two transactions serialize in, the end state is consistent with that
|
|
// order: either the create lost to an effective equivalent (0 effective), or it ran after the
|
|
// revoke (1 effective). Two effective equivalents is the state this pass makes impossible.
|
|
expect(revokeResult.status).toBe("fulfilled");
|
|
const effective = await effectiveCount(subject, racePermission);
|
|
expect(effective).toBeLessThanOrEqual(1);
|
|
if (createResult.status === "rejected") {
|
|
expect(createResult.reason).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
|
expect(effective).toBe(0);
|
|
} else {
|
|
expect(effective).toBe(1);
|
|
}
|
|
});
|
|
|
|
it("rolls back every grant and audit change when the multi-row revocation transaction fails", async () => {
|
|
const doomedPermission = parsePermissionId("calendar.events.doomed");
|
|
const rows = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
|
values ($1, $2, null, 'explicit', null, 'user', $3), ($1, $2, null, 'explicit', null, 'user', $3)
|
|
returning id`,
|
|
[subject, doomedPermission, other],
|
|
);
|
|
// Force the audit insert inside the revoke transaction to violate a real constraint: the audit
|
|
// action CHECK only permits the four known actions, so a temporary trigger that rewrites the
|
|
// action makes the whole multi-row revocation fail after the grant updates were staged.
|
|
await safeQuery(
|
|
admin,
|
|
`create function ekklesia_test_break_audit() returns trigger as $$
|
|
begin new.action := 'not.a.real.action'; return new; end;
|
|
$$ language plpgsql`,
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`create trigger ekklesia_test_break_audit_trigger before insert on "ekklesia_core_authorization_audit"
|
|
for each row execute function ekklesia_test_break_audit()`,
|
|
);
|
|
try {
|
|
let captured: unknown;
|
|
try {
|
|
await store.revokeGrant({
|
|
actor,
|
|
grantId: parseGrantId(rows[0]?.id as string),
|
|
sourceKind: "explicit",
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
|
|
|
const after = await safeQuery<{ revoked_at: Date | null }>(
|
|
admin,
|
|
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
|
[rows.map((row) => row.id)],
|
|
);
|
|
expect(after.every((row) => row.revoked_at === null)).toBe(true);
|
|
const audit = await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
|
[doomedPermission],
|
|
);
|
|
expect(audit).toHaveLength(0);
|
|
} finally {
|
|
// Always restored, even when an assertion above fails.
|
|
await safeQuery(
|
|
admin,
|
|
`drop trigger if exists ekklesia_test_break_audit_trigger on "ekklesia_core_authorization_audit"`,
|
|
);
|
|
await safeQuery(admin, `drop function if exists ekklesia_test_break_audit()`);
|
|
}
|
|
});
|
|
|
|
it("reports duplicate conflict, not-found, and infrastructure failure as three distinct sanitized outcomes", async () => {
|
|
const codes: string[] = [];
|
|
try {
|
|
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
|
|
} catch (error) {
|
|
codes.push((error as AuthorizationStoreError).code);
|
|
}
|
|
try {
|
|
await store.revokeGrant({
|
|
actor,
|
|
grantId: parseGrantId("00000000-0000-4000-8000-000000000000"),
|
|
sourceKind: "explicit",
|
|
});
|
|
} catch (error) {
|
|
codes.push((error as AuthorizationStoreError).code);
|
|
}
|
|
await close();
|
|
try {
|
|
await store.hasActiveGrant({ now: new Date(), permission, userId: subject });
|
|
} catch (error) {
|
|
codes.push((error as AuthorizationStoreError).code);
|
|
}
|
|
|
|
expect(codes).toEqual([
|
|
"AUTHORIZATION_GRANT_ALREADY_EXISTS",
|
|
"AUTHORIZATION_GRANT_NOT_FOUND",
|
|
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
|
|
]);
|
|
await safeEnd(admin);
|
|
});
|
|
});
|
|
|
|
describe.sequential("live Core authorization store — bounded keyset pagination", () => {
|
|
let database: { name: string; url: string };
|
|
let admin: Client;
|
|
let store: ReturnType<typeof createAuthorizationStore>;
|
|
let close: () => Promise<void>;
|
|
|
|
const subject = parseEkklesiaUserId("page_subject");
|
|
|
|
it("seeds explicit and relationship grants, including several sharing one created_at", async () => {
|
|
database = await createDisposableDatabase("pagination");
|
|
await applyCoreMigrations(database.url);
|
|
admin = await openAdministrativeClient(database.url);
|
|
await seedUser(admin, subject, "[email protected]");
|
|
const handle = createAuthorizationDrizzleDatabase(database.url);
|
|
close = handle.close;
|
|
store = createAuthorizationStore(handle.db);
|
|
|
|
// Seven explicit grants all sharing one identical `created_at`, which is exactly the case a
|
|
// timestamp-only keyset boundary would skip or repeat, plus relationship grants that must
|
|
// never appear on any page.
|
|
await safeQuery(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
|
select $1, 'calendar.events.p' || generation, null, 'explicit', null, 'user', $1,
|
|
timestamptz '2026-01-01 00:00:00Z'
|
|
from generate_series(1, 7) as generation`,
|
|
[subject],
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
|
select $1, 'calendar.events.r' || generation, null, 'relationship', 'roster:' || generation, 'user', $1,
|
|
timestamptz '2026-01-01 00:00:00Z'
|
|
from generate_series(1, 3) as generation`,
|
|
[subject],
|
|
);
|
|
expect(
|
|
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [subject]),
|
|
).toHaveLength(10);
|
|
});
|
|
|
|
it("traverses every explicit grant exactly once across pages, excluding relationship grants", async () => {
|
|
const seen: string[] = [];
|
|
let after: { createdAt: Date; id: GrantId } | undefined;
|
|
let pages = 0;
|
|
for (;;) {
|
|
const page = await store.listGrantsForUser(subject, {
|
|
...(after === undefined ? {} : { after }),
|
|
limit: 3,
|
|
sourceKind: "explicit",
|
|
});
|
|
pages += 1;
|
|
seen.push(...page.grants.map((grant) => grant.id));
|
|
expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true);
|
|
if (page.nextBoundary === undefined) {
|
|
expect(page.grants.length).toBeLessThanOrEqual(3);
|
|
break;
|
|
}
|
|
after = page.nextBoundary;
|
|
expect(pages).toBeLessThan(10);
|
|
}
|
|
|
|
// 7 explicit rows at limit 3 → pages of 3, 3, 1. No duplicates, no omissions, deterministic
|
|
// despite every row sharing one created_at.
|
|
expect(pages).toBe(3);
|
|
expect(seen).toHaveLength(7);
|
|
expect(new Set(seen).size).toBe(7);
|
|
// Ordering is created_at DESC, id DESC — with equal timestamps that is a pure descending id
|
|
// sort, which is exactly what makes traversal total and repeatable.
|
|
expect([...seen].sort().reverse()).toEqual(seen);
|
|
});
|
|
|
|
it("honours the 1 and 100 boundaries and rejects anything outside them", async () => {
|
|
expect(
|
|
(await store.listGrantsForUser(subject, { limit: 1, sourceKind: "explicit" })).grants,
|
|
).toHaveLength(1);
|
|
const all = await store.listGrantsForUser(subject, { limit: 100, sourceKind: "explicit" });
|
|
expect(all.grants).toHaveLength(7);
|
|
expect(all.nextBoundary).toBeUndefined();
|
|
for (const limit of [0, 101, -1, 1.5]) {
|
|
await expect(store.listGrantsForUser(subject, { limit })).rejects.toMatchObject({
|
|
code: "AUTHORIZATION_GRANT_LIMIT_INVALID",
|
|
});
|
|
}
|
|
await close();
|
|
await safeEnd(admin);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* A revocation must never succeed by revoking a *replacement* grant it never addressed.
|
|
*
|
|
* `revokeGrant` discovers the addressed grant's equivalence tuple before taking that tuple's
|
|
* advisory lock, because the tuple columns are immutable once written. The all-equivalent update
|
|
* that follows matches on the tuple, not on the addressed id — so without an under-lock
|
|
* revalidation of the target itself, a revoker that paused between its target read and the lock
|
|
* could wake up after the target was revoked and a fresh equivalent created, and then revoke that
|
|
* replacement while reporting success. No serial order explains that outcome, and it contradicts
|
|
* the rule that an already-revoked target keeps not-found behavior.
|
|
*
|
|
* Reproducing it needs the interleaving forced, not raced: an ordinary `Promise.all` would depend
|
|
* on timing luck. The tuple advisory lock is cooperative, so a test-only connection can hold the
|
|
* exact key the store computes — using the identical SQL expression, not a re-derived constant —
|
|
* while ordinary `UPDATE`/`INSERT` statements proceed normally. That is the same test-only
|
|
* administrative provisioning this suite already uses to seed legacy duplicates and force
|
|
* expirations; no production test hook exists to pause the store.
|
|
*/
|
|
describe.sequential("live Core authorization store — stale revocation under the equivalence lock", () => {
|
|
/** Bounded, condition-based wait — never an arbitrary sleep. Returns once a backend is genuinely
|
|
* blocked on an advisory lock in this disposable database, which is the only way to know the
|
|
* stale revocation has passed its target read and reached the lock. */
|
|
async function awaitAdvisoryLockWait(client: Client): Promise<void> {
|
|
const deadline = Date.now() + 20_000;
|
|
while (Date.now() < deadline) {
|
|
const rows = await safeQuery<{ waiting: number }>(
|
|
client,
|
|
`select count(*)::int as waiting
|
|
from pg_locks l
|
|
join pg_stat_activity a on a.pid = l.pid
|
|
where l.locktype = 'advisory'
|
|
and not l.granted
|
|
and a.wait_event_type = 'Lock'
|
|
and a.wait_event = 'advisory'
|
|
and l.database = (select oid from pg_database where datname = current_database())`,
|
|
);
|
|
if (Number(rows[0]?.waiting ?? 0) > 0) {
|
|
return;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
throw harnessError("The stale revocation never reached the advisory-lock wait.");
|
|
}
|
|
|
|
/** An independent outer deadline, so a regression can never hang the suite. The timer is always
|
|
* cleared, including when the awaited promise rejects. */
|
|
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
operation,
|
|
new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(
|
|
() => reject(harnessError("The stale revocation did not settle before its deadline.")),
|
|
timeoutMs,
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
it("rejects a revocation whose addressed target was revoked and replaced while it waited for the tuple lock", async () => {
|
|
const database = await createDisposableDatabase("stale_revoke");
|
|
await applyCoreMigrations(database.url);
|
|
const admin = await openAdministrativeClient(database.url);
|
|
const lockHolder = await openAdministrativeClient(database.url);
|
|
const handle = createAuthorizationDrizzleDatabase(database.url);
|
|
let staleSettled: Promise<unknown> | undefined;
|
|
let lockHeld = false;
|
|
|
|
try {
|
|
const subject = parseEkklesiaUserId("stale_subject");
|
|
const manager = parseEkklesiaUserId("stale_manager");
|
|
await seedUser(admin, subject, "[email protected]");
|
|
await seedUser(admin, manager, "[email protected]");
|
|
const store = createAuthorizationStore(handle.db);
|
|
const actor = createUserActor(manager);
|
|
const permission = parsePermissionId("calendar.events.stale");
|
|
|
|
const firstGrant = await store.createGrant({
|
|
actor,
|
|
permission,
|
|
sourceKind: "explicit",
|
|
userId: subject,
|
|
});
|
|
|
|
// Hold the exact tuple key the store derives, computed with the identical SQL expression.
|
|
await safeQuery(lockHolder, "begin");
|
|
await safeQuery(
|
|
lockHolder,
|
|
`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
|
$1::text, $2::text, null::text, 'explicit'::text, null::text
|
|
)::text, 0))`,
|
|
[subject, permission],
|
|
);
|
|
lockHeld = true;
|
|
|
|
// The stale revoker reads its target successfully, then blocks on the held lock.
|
|
const stale = store.revokeGrant({ actor, grantId: firstGrant.id, sourceKind: "explicit" });
|
|
staleSettled = stale.then(
|
|
() => ({ status: "fulfilled" as const }),
|
|
(error: unknown) => ({ error, status: "rejected" as const }),
|
|
);
|
|
await awaitAdvisoryLockWait(admin);
|
|
|
|
// Committed while the stale revoker waits: the target is revoked and audited, then a fresh
|
|
// equivalent replacement is created — exactly what a concurrent revoker and creator produce.
|
|
await safeQuery(
|
|
admin,
|
|
`update "ekklesia_core_grant"
|
|
set revoked_at = now(), revoked_by_actor_type = 'user', revoked_by_actor_id = $2
|
|
where id = $1`,
|
|
[firstGrant.id, manager],
|
|
);
|
|
await safeQuery(
|
|
admin,
|
|
`insert into "ekklesia_core_authorization_audit"
|
|
(action, actor_type, actor_id, subject_user_id, permission_id, grant_id)
|
|
values ('grant.revoke', 'user', $1, $2, $3, $4)`,
|
|
[manager, subject, permission, firstGrant.id],
|
|
);
|
|
const replacementRows = await safeQuery<{ id: string }>(
|
|
admin,
|
|
`insert into "ekklesia_core_grant"
|
|
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
|
values ($1, $2, null, 'explicit', null, 'user', $3)
|
|
returning id`,
|
|
[subject, permission, manager],
|
|
);
|
|
const replacementId = replacementRows[0]?.id as string;
|
|
expect(replacementId).toBeDefined();
|
|
|
|
// Release the cooperative lock and let the stale revoker resume.
|
|
await safeQuery(lockHolder, "commit");
|
|
lockHeld = false;
|
|
|
|
const outcome = (await withDeadline(staleSettled, 30_000)) as {
|
|
error?: unknown;
|
|
status: "fulfilled" | "rejected";
|
|
};
|
|
|
|
// The addressed target no longer exists in the state it was addressed in.
|
|
expect(outcome.status).toBe("rejected");
|
|
expect(outcome.error).toBeInstanceOf(AuthorizationStoreError);
|
|
expect(outcome.error).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
|
|
|
// The replacement it never addressed is untouched, and no audit row claims otherwise.
|
|
const replacement = await safeQuery<{ revoked_at: Date | null }>(
|
|
admin,
|
|
`select revoked_at from "ekklesia_core_grant" where id = $1`,
|
|
[replacementId],
|
|
);
|
|
expect(replacement[0]?.revoked_at).toBeNull();
|
|
expect(
|
|
await safeQuery(
|
|
admin,
|
|
`select id from "ekklesia_core_authorization_audit"
|
|
where action = 'grant.revoke' and grant_id = $1`,
|
|
[replacementId],
|
|
),
|
|
).toHaveLength(0);
|
|
|
|
// The end state is consistent with a serial order: revoke(G1), create(G2), reject(stale).
|
|
expect(await store.hasActiveGrant({ now: new Date(), permission, userId: subject })).toBe(
|
|
true,
|
|
);
|
|
const effective = await safeQuery<{ n: number }>(
|
|
admin,
|
|
`select count(*)::int as n from "ekklesia_core_grant"
|
|
where user_id = $1 and permission_id = $2 and revoked_at is null`,
|
|
[subject, permission],
|
|
);
|
|
expect(Number(effective[0]?.n)).toBe(1);
|
|
const revokeAudit = await safeQuery<{ grant_id: string }>(
|
|
admin,
|
|
`select grant_id from "ekklesia_core_authorization_audit" where action = 'grant.revoke'`,
|
|
);
|
|
expect(revokeAudit.map((row) => row.grant_id)).toEqual([firstGrant.id]);
|
|
} finally {
|
|
// The lock is released first, so a failed assertion above can never leave the stale
|
|
// revocation blocked forever; every client, pool, promise, and database is then cleaned up.
|
|
if (lockHeld) {
|
|
await safeQuery(lockHolder, "rollback").catch(() => undefined);
|
|
}
|
|
if (staleSettled) {
|
|
await withDeadline(staleSettled, 30_000).catch(() => undefined);
|
|
}
|
|
await safeEnd(lockHolder).catch(() => undefined);
|
|
await safeEnd(admin).catch(() => undefined);
|
|
await handle.close().catch(() => undefined);
|
|
await dropDatabase(database.name).catch(() => undefined);
|
|
}
|
|
});
|
|
});
|