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