ADR 0033 gave Ekklesia a display host and ADR 0034 gave it a login page. Together, the only way to light a television in a foyer was to sign a human being into it and leave that session running on a screen anyone can walk up to: a person's credentials on a wall, a person's session as the thing signage depends on, and the display going dark when they are revoked or their session simply expires. A screen is not a person. Ekklesia now recognizes three principal classes — anonymous, authenticated human, and managed device — and a device is a first-class principal with its own credential and its own explicit permissions. It has no email, no password, and no row in the identity tables, and it satisfies the `authenticated` baseline never, so every route that settles for "somebody is signed in" stays closed to it. Device permissions live in their own table because ekklesia_core_grant.user_id is a non-null foreign key into the user table and making it polymorphic would rewrite the platform's central authorization record to serve one narrow case. That is a schema decision, not a second decision point: the evaluator gained one branch, so a device is allowed or denied by the same function, with the same deny-by-default, as anyone else — with no owner path it could reach and no baseline that stands in for a grant. The six-digit code is not the credential. It is printed across a screen in a public hallway, so it is treated as public: it identifies a request to an administrator and is worth nothing alone. Collecting the credential also requires a 256-bit claim secret held only in the requesting browser's HttpOnly cookie, and approving requires the restricted core.devices.manage permission. Without that split, reading a code off a wall would hand over the screen's identity. The permission a pairing asks for is read from the module's validated display declaration, never from the request, so a browser cannot ask to be granted core.devices.manage or another module's permission. Approval grants exactly that one permission. Credentials are stored only as SHA-256; the raw value exists in the Set-Cookie that issues it and the device's own cookie jar, and is never logged, returned in JSON, or placed in a URL. The cookie is HttpOnly, so the module display code running in that same page cannot read it. Revocation is a server-side column checked on every principal resolution, and re-pairing mints a new identity so an old credential can never come back. State transitions are conditional updates, so one code cannot be approved twice or claimed into two credentials under a race. /api/v1/principal still reports a device as anonymous: it is what the Dashboard uses to decide whether a person is signed in. Displays read their viewer from the descriptor instead, which already performed the authorization — so displays are descriptor-first while the Dashboard stays principal-first, and neither infers an identity by elimination. Proven end to end against real PostgreSQL and real HTTP: an unpaired screen shows a code, an owner approves it, the device opens the display descriptor and a realtime stream, and revocation closes both while the device still holds its cookie. An already-open SSE stream is bounded by realtime's existing five-minute reconnect rather than closed instantly, and that bound is documented rather than called immediate. Co-Authored-By: Claude Opus 5 <[email protected]>
1300 lines
48 KiB
TypeScript
1300 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",
|
|
]);
|
|
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);
|
|
}
|
|
});
|
|
});
|