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
+122 -19
View File
@@ -1,8 +1,9 @@
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
@@ -15,10 +16,37 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
};
}
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
* specific decision or store behavior override the relevant method explicitly. */
function fakeAuthorization(overrides: {
readonly evaluator?: Partial<AuthorizationEvaluator>;
readonly store?: Partial<AuthorizationStore>;
}): CreateApiAppAuthorizationOptions {
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
...overrides.evaluator,
};
const store: AuthorizationStore = {
createGrant: vi.fn(),
grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]),
revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(),
...overrides.store,
};
return { evaluator, store };
}
describe("Ekklesia API Hono application", () => {
it("mounts Better Auth exactly under /api/auth/*", async () => {
const runtime = fakeRuntime();
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
@@ -29,7 +57,11 @@ describe("Ekklesia API Hono application", () => {
it("does not route requests outside /api/auth/* to Better Auth", async () => {
const runtime = fakeRuntime();
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/sign-in/email", { method: "POST" });
@@ -41,7 +73,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
});
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
@@ -55,7 +91,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
});
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
const body = (await response.json()) as Record<string, unknown>;
@@ -71,7 +111,11 @@ describe("Ekklesia API Hono application", () => {
.fn()
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
});
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
@@ -84,7 +128,11 @@ describe("Ekklesia API Hono application", () => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
});
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
const body = await response.json();
@@ -96,7 +144,11 @@ describe("Ekklesia API Hono application", () => {
});
it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/does-not-exist");
@@ -105,7 +157,11 @@ describe("Ekklesia API Hono application", () => {
});
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin },
@@ -116,7 +172,11 @@ describe("Ekklesia API Hono application", () => {
});
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: untrustedOrigin },
@@ -126,7 +186,11 @@ describe("Ekklesia API Hono application", () => {
});
it("supports requests with no Origin header at all (native/server clients)", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal");
@@ -134,7 +198,11 @@ describe("Ekklesia API Hono application", () => {
});
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: {
@@ -149,7 +217,11 @@ describe("Ekklesia API Hono application", () => {
});
it("never wildcards Access-Control-Allow-Origin", async () => {
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/principal", {
headers: { origin: trustedOrigin },
@@ -158,10 +230,41 @@ describe("Ekklesia API Hono application", () => {
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
});
// "No generalized session/user object stored in Hono context" is verified by construction
// rather than by a black-box context probe: unlike Better Auth's own documented Hono example
// (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls
// `c.set` at all — the only per-request identity data that ever exists is the narrow
// EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact
// shape (kind + userId only) is already asserted by the principal-shape tests in this file.
// Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)`
// on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal
// (kind + userId only), and only on routes `requirePermission` actually protects — proven by the
// grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still
// never touches `c.set`; its response shape is asserted directly above.
describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => {
it("mounts the v1 grants routes under /api/v1/grants", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
const app = createApiApp({
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123");
// Reaching the permission check (and being denied by it) proves the route is mounted and
// wired to the injected evaluator, rather than falling through to the 404 handler.
expect(response.status).toBe(403);
expect(evaluatorAuthorize).toHaveBeenCalledTimes(1);
});
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
const app = createApiApp({
authorization: fakeAuthorization({}),
runtime: fakeRuntime(),
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/v1/grants?userId=user_123", {
headers: { origin: untrustedOrigin },
});
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
});
});