Files
Ekklesia/core/tests/authorization-store.integration.ts
T

540 lines
17 KiB
TypeScript

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