Files
Ekklesia/core/tests/authorization-store.integration.ts
T
ekklesiaandClaude Opus 5 80189c8ae2
CI / verify (pull_request) Successful in 40s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m22s
feat: give display devices their own paired identity
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]>
2026-08-23 23:41:03 -05:00

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);
}
});
});