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
@@ -0,0 +1,204 @@
import {
ANONYMOUS_PRINCIPAL,
createAuthenticatedPrincipal,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
createPermissionRegistry,
} from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import {
type AuthorizationMiddlewareOptions,
getAuthorizedPrincipal,
requirePermission,
requireTrustedOrigin,
} from "../src/authorization-middleware.js";
const testPermission = parsePermissionId("core.test.restricted-thing");
const unregisteredPermission = parsePermissionId("core.test.unregistered-thing");
const registry = createPermissionRegistry([
{ baseline: "restricted", description: "A restricted test permission.", id: testPermission },
]);
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
return {
handleAuthRequest: vi.fn(),
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
...overrides,
};
}
function fakeEvaluator(overrides: Partial<AuthorizationEvaluator> = {}): AuthorizationEvaluator {
return {
authorize: vi.fn().mockResolvedValue({ allowed: true }),
...overrides,
};
}
function buildTestApp(options: Partial<AuthorizationMiddlewareOptions> = {}) {
const runtime = options.runtime ?? fakeRuntime();
const evaluator = options.evaluator ?? fakeEvaluator();
const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime };
const app = new Hono();
app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => {
const principal = getAuthorizedPrincipal(c);
return c.json({ principalKind: principal.kind });
});
app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) =>
c.json({ ok: true }),
);
app.get("/unprotected", (c) => {
let threw = false;
try {
getAuthorizedPrincipal(c);
} catch {
threw = true;
}
return c.json({ threw });
});
return { app, evaluator, runtime };
}
describe("requirePermission", () => {
it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
});
const { app } = buildTestApp({ runtime });
const response = await app.request("/protected");
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ principalKind: "authenticated" });
});
it("returns a sanitized 403 when the evaluator denies", async () => {
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(response.headers.get("cache-control")).toBe("no-store");
});
it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => {
const runtime = fakeRuntime();
const { app } = buildTestApp({ runtime });
const response = await app.request("/unregistered");
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
});
it("returns a sanitized 503, not a 403, when principal resolution fails", async () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
});
const { app } = buildTestApp({ runtime });
const response = await app.request("/protected");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
});
it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => {
const evaluator = fakeEvaluator({
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
});
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
});
it("never leaks a raw infrastructure error message through either sanitized 503", async () => {
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
const evaluator = fakeEvaluator({
authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)),
});
const { app } = buildTestApp({ evaluator });
const response = await app.request("/protected");
const text = await response.text();
expect(text).not.toContain("hunter2");
expect(text).not.toContain("db.internal.example");
});
it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => {
const evaluator = fakeEvaluator();
const { app } = buildTestApp({ evaluator });
// A hostile client cannot even express "use this baseline instead" — there is no request
// field the middleware reads for it — but sending an unrelated header proves it is ignored.
await app.request("/protected", { headers: { "x-baseline": "public" } });
expect(evaluator.authorize).toHaveBeenCalledWith(
expect.objectContaining({ baseline: "restricted", permission: testPermission }),
);
});
});
describe("getAuthorizedPrincipal", () => {
it("throws when called on a route requirePermission never protected", async () => {
const { app } = buildTestApp();
const response = await app.request("/unprotected");
expect(await response.json()).toEqual({ threw: true });
});
});
describe("requireTrustedOrigin", () => {
const trustedOrigins = new Set(["https://dashboard.example.test"]);
function buildOriginApp() {
const app = new Hono();
app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true }));
return app;
}
it("allows a request from a trusted origin", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", {
headers: { origin: "https://dashboard.example.test" },
method: "POST",
});
expect(response.status).toBe(200);
});
it("rejects a request with no Origin header at all", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", { method: "POST" });
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
it("rejects a request from an untrusted origin", async () => {
const app = buildOriginApp();
const response = await app.request("/mutate", {
headers: { origin: "https://evil.example.test" },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
});
});