feat: add core authorization foundation

This commit is contained in:
ekklesia
2026-07-23 10:29:01 -05:00
parent 3fa1c4cdec
commit 72e082ff2f
23 changed files with 3029 additions and 3 deletions
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import {
AuthorizationDatabaseError,
createAuthorizationDrizzleDatabase,
} from "../src/authorization/database.js";
describe("Core authorization Drizzle database composition", () => {
it("does not construct a pool merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it.each([
"",
" ",
"not-a-database-url",
"https://admin:[email protected]/database",
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
let captured: unknown;
try {
createAuthorizationDrizzleDatabase(connectionString);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
if (connectionString) {
expect(publicText).not.toContain(connectionString);
}
expect(poolConstructor).not.toHaveBeenCalled();
});
it("explicitly constructs the pool only when called, with the authorization schema attached", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
const handle = createAuthorizationDrizzleDatabase(
"postgresql://operator:[email protected]/ekklesia",
);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(handle.db).toBeDefined();
expect(typeof handle.close).toBe("function");
expect(Object.isFrozen(handle)).toBe(true);
});
it("passes the connection timeout through to the pool configuration", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 5_000,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
);
});
it("passes the operation timeout through to the pool configuration as both query_timeout and statement_timeout", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
operationTimeoutMs: 7_500,
});
expect(poolConstructor).toHaveBeenCalledWith(
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
);
});
it("does not set query_timeout, statement_timeout, or connectionTimeoutMillis when no timeout is configured", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn() };
});
poolConstructor.mockClear();
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const [poolConfig] = poolConstructor.mock.calls[0] as [Record<string, unknown>];
expect(poolConfig).not.toHaveProperty("query_timeout");
expect(poolConfig).not.toHaveProperty("statement_timeout");
expect(poolConfig).not.toHaveProperty("connectionTimeoutMillis");
});
it.each([
["operationTimeoutMs", 0],
["operationTimeoutMs", -1],
["operationTimeoutMs", Number.NaN],
["operationTimeoutMs", 1.5],
["operationTimeoutMs", 2_147_483_648],
["connectionTimeoutMs", 0],
["connectionTimeoutMs", -1],
["connectionTimeoutMs", Number.NaN],
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
poolConstructor.mockClear();
let captured: unknown;
try {
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
[optionName]: value,
});
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(String(value));
expect(poolConstructor).not.toHaveBeenCalled();
});
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
const on = vi.fn();
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on };
});
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
| ((error: unknown) => void)
| undefined;
expect(() =>
handler?.(new Error("terminating connection due to administrator command")),
).not.toThrow();
});
});
+500
View File
@@ -0,0 +1,500 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parsePermissionId,
} from "@ekklesia/contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type ActiveGrantQuery,
AuthorizationEvaluationError,
type AuthorizationGrantPort,
type AuthorizationPlatformOwnerPort,
type AuthorizeRequest,
createAuthorizationEvaluator,
} from "../src/authorization/index.js";
interface FakeGrant {
readonly userId: string;
readonly permission: string;
readonly scope?: string;
readonly expiresAt?: Date;
readonly revokedAt?: Date;
}
/** An in-memory double implementing the exact scope/expiry/revocation matching semantics the real
* Postgres store implements in SQL, so the evaluator's own decision logic can be exercised in
* isolation. The live integration suite separately proves the SQL performs the same matching. */
class FakeGrantStore implements AuthorizationGrantPort {
readonly grants: FakeGrant[] = [];
readonly calls: ActiveGrantQuery[] = [];
async hasActiveGrant(query: ActiveGrantQuery): Promise<boolean> {
this.calls.push(query);
return this.grants.some((grant) => {
if (grant.userId !== query.userId || grant.permission !== query.permission) {
return false;
}
if (grant.revokedAt !== undefined) {
return false;
}
if (grant.expiresAt !== undefined && grant.expiresAt <= query.now) {
return false;
}
if (grant.scope === undefined) {
return true; // an unscoped grant satisfies any request for the same permission
}
return grant.scope === query.scope; // a scoped grant never satisfies an unscoped request
});
}
}
class FakeOwnerStore implements AuthorizationPlatformOwnerPort {
private readonly owners = new Set<string>();
readonly calls: string[] = [];
makeOwner(userId: string): void {
this.owners.add(userId);
}
async isPlatformOwner(userId: string): Promise<boolean> {
this.calls.push(userId);
return this.owners.has(userId);
}
}
const userId = parseEkklesiaUserId("user_1");
const otherUserId = parseEkklesiaUserId("user_2");
const permission = parsePermissionId("calendar.events.read");
const scope = parseAuthorizationScope("calendar-event:123");
const otherScope = parseAuthorizationScope("calendar-event:456");
describe("Core authorization evaluator", () => {
let grants: FakeGrantStore;
let owners: FakeOwnerStore;
beforeEach(() => {
grants = new FakeGrantStore();
owners = new FakeOwnerStore();
});
function evaluator(now?: () => Date) {
return createAuthorizationEvaluator({
grants,
platformOwners: owners,
...(now ? { now } : {}),
});
}
describe("public baseline", () => {
it("allows an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("never queries grants or platform-owner state for a public check", async () => {
await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("authenticated baseline", () => {
it("allows an authenticated principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("rejects an anonymous principal", async () => {
const decision = await evaluator().authorize({
baseline: "authenticated",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
});
it("never queries grants or platform-owner state for an authenticated check", async () => {
await evaluator().authorize({
baseline: "authenticated",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
});
describe("restricted baseline", () => {
it("denies an anonymous principal without ever consulting grants or ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
expect(owners.calls).toHaveLength(0);
});
it("denies an authenticated principal with no grant and no ownership", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows an authenticated principal holding an active explicit grant", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows an authenticated principal holding an active relationship-derived grant", async () => {
// The evaluator only distinguishes "active grant or not" through the port; source kind is a
// store/audit concern, not an evaluation input — proven here by a grant the fake store
// exposes with no source-kind field at all still being honored.
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("matches permissions exactly and does not treat a namespace prefix as inherited authority", async () => {
grants.grants.push({ permission: parsePermissionId("calendar.events.read.detail"), userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a grant that belongs to a different user", async () => {
grants.grants.push({ permission, userId: otherUserId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("lets an unscoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("lets an unscoped grant satisfy a scoped request for the same permission", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("lets a scoped grant satisfy a request for the exact matching scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("denies a scoped grant against a different scope", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: otherScope,
});
expect(decision).toEqual({ allowed: false });
});
it("never lets a scoped grant satisfy an unscoped request", async () => {
grants.grants.push({ permission, scope, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an expired grant", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2025-12-31T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("allows a grant that has not yet expired, evaluated deterministically against an injected clock", async () => {
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
grants.grants.push({
expiresAt: new Date("2026-01-02T00:00:00.000Z"),
permission,
userId,
});
const decision = await evaluator(() => fixedNow).authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("denies a revoked grant even though it has not expired", async () => {
grants.grants.push({
expiresAt: new Date(Date.now() + 86_400_000),
permission,
revokedAt: new Date(),
userId,
});
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies an unknown or malformed permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "not a valid permission id",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
expect(grants.calls).toHaveLength(0);
});
it("denies a wildcard-shaped permission without throwing", async () => {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: "media.*",
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
it("denies a non-string permission (number, object, null, undefined) without throwing", async () => {
for (const malformed of [42, {}, null, undefined, ["calendar.events.read"]]) {
const decision = await evaluator().authorize({
baseline: "restricted",
permission: malformed,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
it("denies a malformed scope without throwing", async () => {
grants.grants.push({ permission, userId });
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope: "",
});
expect(decision).toEqual({ allowed: false });
});
it("allows a platform owner with no matching grant at all", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: true });
});
it("allows a platform owner for a scoped restricted permission with no scoped grant", async () => {
owners.makeOwner(userId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
scope,
});
expect(decision).toEqual({ allowed: true });
});
it("does not grant ownership authority to a different user", async () => {
owners.makeOwner(otherUserId);
const decision = await evaluator().authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
});
});
describe("an unknown or malformed baseline", () => {
it("denies rather than defaulting to any known baseline", async () => {
for (const malformed of ["superuser", "", 42, null, undefined, "Public", "PUBLIC"]) {
const decision = await evaluator().authorize({
baseline: malformed,
permission,
principal: createAuthenticatedPrincipal(userId),
});
expect(decision).toEqual({ allowed: false });
}
});
});
describe("a client cannot inject owner or grant state", () => {
it("ignores extra caller-supplied fields such as isOwner/hasGrant and re-derives authority from the injected ports", async () => {
// Simulates a caller that smuggles extra fields the AuthorizeRequest type does not
// declare — going through `unknown` rather than `any` proves the object still satisfies
// AuthorizeRequest structurally; the extra fields are simply never read.
const hostileRequest = {
baseline: "restricted",
hasGrant: true,
isOwner: true,
permission,
principal: createAuthenticatedPrincipal(userId),
} as unknown as AuthorizeRequest;
const decision = await evaluator().authorize(hostileRequest);
expect(decision).toEqual({ allowed: false });
// The evaluator still had to actually ask the injected ports — it did not shortcut on the
// hostile flags.
expect(grants.calls).toHaveLength(1);
expect(owners.calls).toHaveLength(1);
});
});
describe("decision contract", () => {
it("returns a frozen, minimal decision with no extra fields", async () => {
const decision = await evaluator().authorize({
baseline: "public",
permission,
principal: ANONYMOUS_PRINCIPAL,
});
expect(Object.isFrozen(decision)).toBe(true);
expect(Object.keys(decision)).toEqual(["allowed"]);
});
});
describe("sanitized failures", () => {
it("propagates a genuine grant-lookup infrastructure failure rather than silently denying", async () => {
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toMatchObject({
code: "AUTHORIZATION_EVALUATION_FAILED",
});
});
it("propagates a genuine platform-owner lookup infrastructure failure rather than silently denying", async () => {
const failingOwners: AuthorizationPlatformOwnerPort = {
isPlatformOwner: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
};
const failingEvaluator = createAuthorizationEvaluator({
grants,
platformOwners: failingOwners,
});
await expect(
failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
}),
).rejects.toBeInstanceOf(AuthorizationEvaluationError);
});
it("never leaks a raw infrastructure error message through the thrown error", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const failingGrants: AuthorizationGrantPort = {
hasActiveGrant: vi.fn().mockRejectedValue(new Error(hostileMessage)),
};
const failingEvaluator = createAuthorizationEvaluator({
grants: failingGrants,
platformOwners: owners,
});
let captured: unknown;
try {
await failingEvaluator.authorize({
baseline: "restricted",
permission,
principal: createAuthenticatedPrincipal(userId),
});
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("hunter2");
expect(publicText).not.toContain("db.internal.example");
});
});
});
@@ -0,0 +1,539 @@
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);
}
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor }));
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js";
describe("Core authorization store — import and construction safety", () => {
it("does not construct a pool or open a connection merely because the module is imported", () => {
expect(poolConstructor).not.toHaveBeenCalled();
});
it("does not perform any query merely because the store is constructed", () => {
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn(), query: vi.fn() };
});
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const store = createAuthorizationStore(db);
expect(store).toBeDefined();
expect(typeof store.createGrant).toBe("function");
expect(typeof store.revokeGrant).toBe("function");
expect(typeof store.hasActiveGrant).toBe("function");
expect(typeof store.grantPlatformOwner).toBe("function");
expect(typeof store.revokePlatformOwner).toBe("function");
expect(typeof store.isPlatformOwner).toBe("function");
expect(Object.isFrozen(store)).toBe(true);
});
});
describe("AuthorizationStoreError", () => {
it.each([
"AUTHORIZATION_GRANT_CREATE_FAILED",
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
"AUTHORIZATION_GRANT_NOT_FOUND",
"AUTHORIZATION_GRANT_REVOKE_FAILED",
"AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED",
"AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED",
"AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND",
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
const error = new AuthorizationStoreError(code);
expect(error.code).toBe(code);
expect(error.name).toBe("AuthorizationStoreError");
expect(Object.isFrozen(error)).toBe(true);
expect(error.message.length).toBeGreaterThan(0);
});
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
const error = new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
const publicText = `${String(error)}\n${error.stack ?? ""}`;
expect(publicText).toBe(
`AuthorizationStoreError: ${error.message}\nAuthorizationStoreError: ${error.message}`,
);
});
});