feat: add authorization bootstrap and HTTP enforcement

Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+281
View File
@@ -2,6 +2,12 @@ import { randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
@@ -812,3 +818,278 @@ describe.sequential("live API — Better Auth logging never leaks operational de
}
});
});
describe.sequential("live API — platform owner and delegated grant management", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ownerUserId: string;
let ownerCookie: string;
let managerUserId: string;
let managerCookie: string;
let createdGrantId: string;
const ownerEmail = "[email protected]";
const managerEmail = "[email protected]";
const password = "correct horse battery staple";
const testPermission = "calendar.events.read";
it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => {
database = await createDisposableDatabase("authz_http");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url);
origin = apiOrigin(running);
const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id;
const managerSignUp = await signUp(origin, managerEmail, password);
managerUserId = managerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password);
expect(ownerSignIn.cookiePair).toBeDefined();
ownerCookie = ownerSignIn.cookiePair as string;
});
it("denies grants access to an anonymous caller", async () => {
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("denies grants access to an authenticated caller with no owner authority and no grant", async () => {
const managerSignInBefore = await signIn(origin, managerEmail, password);
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: managerSignInBefore.cookiePair as string },
});
expect(response.status).toBe(403);
});
it("bootstraps the owner via Core's real bootstrap operation, then confirms owner authority over the HTTP grants API", async () => {
// Exactly what `pnpm authorization:bootstrap-owner` would do, driven directly against the
// same disposable database the running API already uses — no second process needed to prove
// the HTTP layer honors real platform-owner state.
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
} finally {
await close();
}
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: ownerCookie },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ grants: [] });
});
it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: "core.authorization.grants.manage",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as { grant: { sourceKind: string; userId: string } };
expect(response.status).toBe(201);
expect(body.grant.sourceKind).toBe("explicit");
expect(body.grant.userId).toBe(managerUserId);
});
it("lets the delegated manager — who is not a platform owner — manage another user's grants", async () => {
const managerSignIn = await signIn(origin, managerEmail, password);
expect(managerSignIn.cookiePair).toBeDefined();
managerCookie = managerSignIn.cookiePair as string;
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: managerCookie },
});
expect(response.status).toBe(200);
});
it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: testPermission,
scope: "calendar-event:1",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: managerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as {
grant: { id: string; sourceKind: string; userId: string };
};
expect(response.status).toBe(201);
expect(body.grant.sourceKind).toBe("explicit");
expect(body.grant.userId).toBe(managerUserId);
createdGrantId = body.grant.id;
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string; actor_id: string | null }>(
admin,
`select action, actor_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
[createdGrantId],
);
expect(rows).toEqual([{ action: "grant.create", actor_id: managerUserId }]);
} finally {
await safeEnd(admin);
}
});
it("lists the explicit grant via HTTP", async () => {
const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
headers: { cookie: ownerCookie },
});
const body = (await response.json()) as { grants: Array<{ id: string }> };
expect(response.status).toBe(200);
expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
});
it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
actor: { type: "system" },
permission: testPermission,
sourceKind: "relationship",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(400);
});
it("rejects a mutation request with no trusted Origin header, and never returns a permissive CORS header", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("rejects a mutation request from an untrusted Origin the same way", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("revokes the explicit grant via HTTP and records the revocation audit entry", async () => {
const response = await fetch(`${origin}/api/v1/grants/${createdGrantId}/revoke`, {
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
[createdGrantId],
);
expect(rows.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
} finally {
await safeEnd(admin);
}
});
it("never exposes a relationship-derived grant through the generic list, create, or revoke HTTP API", async () => {
// Seeded directly through the store, simulating a module's own business operation — never
// through HTTP, which has no way to express sourceKind at all.
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
let relationshipGrantId: string;
try {
const store = createAuthorizationStore(db);
const relationshipGrant = await store.createGrant({
actor: SYSTEM_ACTOR,
permission: parsePermissionId("roster.volunteer.read"),
sourceKind: "relationship",
sourceRef: "roster:volunteer:99",
userId: parseEkklesiaUserId(managerUserId),
});
relationshipGrantId = relationshipGrant.id;
} finally {
await close();
}
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
headers: { cookie: ownerCookie },
});
const listBody = (await listResponse.json()) as { grants: Array<{ id: string }> };
expect(listBody.grants.some((grant) => grant.id === relationshipGrantId)).toBe(false);
const revokeResponse = await fetch(`${origin}/api/v1/grants/${relationshipGrantId}/revoke`, {
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
});
expect(revokeResponse.status).toBe(404);
expect(await revokeResponse.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
});
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
});
describe.sequential("live API — authorization infrastructure failure is never downgraded to a false denial", () => {
it("returns a sanitized 503, not a 403, when the database becomes unreachable mid-session", async () => {
const database = await createDisposableDatabase("authz_infra_failure");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
const origin = apiOrigin(running);
await signUp(origin, "[email protected]", "correct horse battery staple");
const { cookiePair } = await signIn(
origin,
"[email protected]",
"correct horse battery staple",
);
expect(cookiePair).toBeDefined();
// Force the database itself unreachable out from under the running API pool — the same
// technique the identity infra-failure test above uses, applied to a grants route this time.
await dropDatabase(database.name);
const response = await fetch(`${origin}/api/v1/grants?userId=does-not-matter`, {
headers: { cookie: cookiePair as string },
});
expect(response.status).toBe(503);
const body = (await response.json()) as { code: string };
expect(["PRINCIPAL_RESOLUTION_FAILED", "AUTHORIZATION_EVALUATION_FAILED"]).toContain(body.code);
await running.shutdown().catch(() => undefined);
});
});