540 lines
17 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|