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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user