Merge pull request 'phase-4/core-authorization' (#10) from phase-4/core-authorization into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/10
This commit was merged in pull request #10.
This commit is contained in:
@@ -6,8 +6,8 @@ surfaces.
|
||||
|
||||
## Current status
|
||||
|
||||
Phase 3 is complete, and Phase 4A (Core identity foundation) and Phase 4B (identity HTTP
|
||||
transport) have landed. The repository includes
|
||||
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
|
||||
and Phase 4C (Core authorization) have landed. The repository includes
|
||||
public module contracts, independently versioned capabilities, static module manifests,
|
||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||
@@ -35,10 +35,21 @@ still cannot reach any of this. Public email/password registration is intentiona
|
||||
receives identical lowest-privilege baseline status and signup order never grants elevation. See
|
||||
[API](docs/development/api.md) and
|
||||
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||
**No email verification, password recovery, Dashboard, or role/permission model exists yet** —
|
||||
that, along with production approval-provider composition, jobs, physical storage provisioning,
|
||||
surfaces, and feature modules, remains later vertical slices. Public registration is not yet ready
|
||||
for a real public launch.
|
||||
**No email verification, password recovery, or Dashboard exists yet** — that, along with
|
||||
production approval-provider composition, jobs, physical storage provisioning, surfaces, and
|
||||
feature modules, remains later vertical slices. Public registration is not yet ready for a real
|
||||
public launch.
|
||||
|
||||
Phase 4C adds Core's authorization foundation on top of identity: a schema, evaluator, and store
|
||||
for platform owners, explicit grants, and relationship-derived grants; a smallest-possible
|
||||
immutable permission registry; an explicit, single-use, concurrency-safe platform-owner bootstrap
|
||||
command (`pnpm authorization:bootstrap-owner`, never HTTP-exposed, never inferred from signup
|
||||
order); and, in `apps/api`, reusable Hono authorization enforcement plus a narrowly scoped
|
||||
`/api/v1/grants` HTTP API for managing *explicit* grants only, requiring an exact trusted `Origin`
|
||||
on every mutation. Roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
||||
accounts/API tokens, and module-permission registration remain later work. See
|
||||
[Authorization](docs/development/authorization.md) and
|
||||
[ADR 0013](docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -83,8 +94,9 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
1 migration · checksums valid
|
||||
2 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
|
||||
PostgreSQL
|
||||
○ Connection check skipped
|
||||
@@ -146,6 +158,7 @@ environment.
|
||||
- [Testing](docs/development/testing.md)
|
||||
- [Identity](docs/development/identity.md)
|
||||
- [API](docs/development/api.md)
|
||||
- [Authorization](docs/development/authorization.md)
|
||||
- [Module development](docs/module-development/README.md)
|
||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||
|
||||
|
||||
+27
-2
@@ -1,13 +1,27 @@
|
||||
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { createGrantsRoutes } from "./grants-routes.js";
|
||||
|
||||
export interface CreateApiAppAuthorizationOptions {
|
||||
readonly evaluator: AuthorizationEvaluator;
|
||||
readonly store: AuthorizationStore;
|
||||
}
|
||||
|
||||
export interface CreateApiAppOptions {
|
||||
readonly authorization: CreateApiAppAuthorizationOptions;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. */
|
||||
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set
|
||||
* `requireTrustedOrigin` checks for grant-mutation routes — there is no second, competing origin
|
||||
* configuration. */
|
||||
readonly trustedOrigins: readonly string[];
|
||||
}
|
||||
|
||||
@@ -20,7 +34,7 @@ function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||
/**
|
||||
* Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable
|
||||
* for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime
|
||||
* standing in for real Better Auth/PostgreSQL where appropriate.
|
||||
* and authorization evaluator/store standing in for real Better Auth/PostgreSQL where appropriate.
|
||||
*/
|
||||
export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
const { runtime } = options;
|
||||
@@ -62,6 +76,17 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}
|
||||
});
|
||||
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime,
|
||||
store: options.authorization.store,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
app.notFound((c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code: "NOT_FOUND" }, 404);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts";
|
||||
import type { AuthorizationEvaluator, PermissionRegistry } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import type { Context, MiddlewareHandler, Next } from "hono";
|
||||
|
||||
const PRINCIPAL_CONTEXT_KEY = "ekklesiaPrincipal";
|
||||
|
||||
export interface AuthorizationMiddlewareOptions {
|
||||
readonly evaluator: AuthorizationEvaluator;
|
||||
readonly registry: PermissionRegistry;
|
||||
readonly runtime: EkklesiaIdentityRuntime;
|
||||
}
|
||||
|
||||
function sanitizedDenial(c: Context, status: 403 | 503, code: string): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
return c.json({ code }, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Hono middleware that enforces `permissionId`, resolved exclusively from Core's own
|
||||
* trusted, server-side permission registry — a route never supplies its own baseline, and no
|
||||
* request input can influence which baseline is checked. The principal is resolved exclusively
|
||||
* through the injected identity runtime (`resolvePrincipal`), the same one `/api/v1/principal`
|
||||
* already uses; nothing here ever trusts a client-supplied claim about identity, ownership, or
|
||||
* grants. A permission id that is not registered fails closed (403) exactly like a denied
|
||||
* decision — in normal operation this route only ever hard-codes an already-registered constant,
|
||||
* but the check stays live per-request as defense in depth rather than being trusted once at
|
||||
* composition time.
|
||||
*
|
||||
* On success, the resolved `EkklesiaPrincipal` — Core's own narrow `{kind, userId}` contract, not
|
||||
* Better Auth's richer session/user object — is stored on the Hono context so the route handler
|
||||
* can build an actor without resolving the principal a second time. This is deliberately narrower
|
||||
* than the "no generalized session/user object in Hono context" concern ADR 0011 raised about
|
||||
* Better Auth's own documented example: only Core's already-public-safe principal shape is ever
|
||||
* set, never a session token, raw user row, or Better Auth type.
|
||||
*
|
||||
* A genuine infrastructure failure — principal resolution, evaluation, or the underlying
|
||||
* database — is never folded into a 403; it returns a sanitized 503, mirroring
|
||||
* `/api/v1/principal`'s existing behavior for the identical reason: a caller must never see "no
|
||||
* permission" when the true answer is "could not be determined."
|
||||
*/
|
||||
export function requirePermission(
|
||||
options: AuthorizationMiddlewareOptions,
|
||||
permissionId: PermissionId,
|
||||
): MiddlewareHandler {
|
||||
return async (c: Context, next: Next) => {
|
||||
const declaration = options.registry.get(permissionId);
|
||||
if (!declaration) {
|
||||
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
|
||||
let principal: EkklesiaPrincipal;
|
||||
try {
|
||||
principal = await options.runtime.resolvePrincipal(c.req.raw.headers);
|
||||
} catch {
|
||||
return sanitizedDenial(c, 503, "PRINCIPAL_RESOLUTION_FAILED");
|
||||
}
|
||||
|
||||
let decision: { allowed: boolean };
|
||||
try {
|
||||
decision = await options.evaluator.authorize({
|
||||
baseline: declaration.baseline,
|
||||
permission: declaration.id,
|
||||
principal,
|
||||
});
|
||||
} catch {
|
||||
return sanitizedDenial(c, 503, "AUTHORIZATION_EVALUATION_FAILED");
|
||||
}
|
||||
|
||||
if (!decision.allowed) {
|
||||
return sanitizedDenial(c, 403, "AUTHORIZATION_DENIED");
|
||||
}
|
||||
|
||||
c.set(PRINCIPAL_CONTEXT_KEY, principal);
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the principal a preceding `requirePermission` middleware already resolved and verified.
|
||||
* Throws (a route-wiring bug, not a runtime condition) if called on a route this middleware never
|
||||
* protected. */
|
||||
export function getAuthorizedPrincipal(c: Context): EkklesiaPrincipal {
|
||||
const principal = c.get(PRINCIPAL_CONTEXT_KEY) as EkklesiaPrincipal | undefined;
|
||||
if (!principal) {
|
||||
throw new Error("getAuthorizedPrincipal called outside a requirePermission-protected route.");
|
||||
}
|
||||
return principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires an exact trusted `Origin` header for a cookie-authenticated, state-changing route.
|
||||
* CORS response headers alone are not CSRF protection — they only stop a browser from *reading* a
|
||||
* cross-origin response, not from *sending* the state-changing request in the first place — so
|
||||
* mutation routes need this explicit, server-side check in addition to (not instead of) the
|
||||
* existing CORS policy. A missing or untrusted origin is rejected before the route (or the
|
||||
* permission check) ever runs. Non-browser, non-cookie automation (service accounts/API tokens)
|
||||
* is deliberately out of scope for this slice and has no way to satisfy this check yet.
|
||||
*/
|
||||
export function requireTrustedOrigin(trustedOrigins: ReadonlySet<string>): MiddlewareHandler {
|
||||
return async (c: Context, next: Next) => {
|
||||
const origin = c.req.header("origin");
|
||||
if (!origin || !trustedOrigins.has(origin)) {
|
||||
return sanitizedDenial(c, 403, "UNTRUSTED_ORIGIN");
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database";
|
||||
import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly identity: IdentityDrizzleDatabase;
|
||||
/** Closes the one shared pool exactly once, regardless of how many typed views were created
|
||||
* over it. Safe to call from both a startup-failure cleanup path and normal shutdown. */
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the one physical PostgreSQL pool this process uses for both Core's identity and
|
||||
* authorization schemas: one validated, timeout-bounded `pg.Pool` (`createValidatedPostgresPool`,
|
||||
* `@ekklesia/core/database`), wrapped by two independent typed Drizzle views
|
||||
* (`createIdentityDrizzleView`, `createAuthorizationDrizzleView`). Neither Core domain owns or
|
||||
* closes the pool in this composition — `close()` here is the only thing that ever calls
|
||||
* `pool.end()`. `createIdentityDrizzleDatabase`/`createAuthorizationDrizzleDatabase` remain
|
||||
* available, unaffected, and independently testable for callers that only need one domain.
|
||||
*/
|
||||
export function createApiDatabase(
|
||||
databaseUrl: string,
|
||||
options: PostgresPoolOptions = {},
|
||||
): ApiDatabaseHandle {
|
||||
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
||||
|
||||
return Object.freeze({
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
identity: createIdentityDrizzleView(pool),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
import {
|
||||
createUserActor,
|
||||
isAuthenticatedPrincipal,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseEkklesiaUserId,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationStore,
|
||||
AuthorizationStoreError,
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
type GrantRecord,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "./authorization-middleware.js";
|
||||
|
||||
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
|
||||
readonly store: AuthorizationStore;
|
||||
readonly trustedOrigins: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||
c.header("Cache-Control", "no-store");
|
||||
if (error instanceof AuthorizationStoreError) {
|
||||
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503;
|
||||
return c.json({ code: error.code }, status);
|
||||
}
|
||||
return c.json({ code: "INTERNAL_ERROR" }, 503);
|
||||
}
|
||||
|
||||
interface ParsedCreateGrantBody {
|
||||
readonly expiresAt?: Date;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]);
|
||||
|
||||
/**
|
||||
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`.
|
||||
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`,
|
||||
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the
|
||||
* whole body invalid rather than being silently dropped, so a caller cannot smuggle
|
||||
* server-derived fields through a request that otherwise looks well-formed.
|
||||
*/
|
||||
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const body = raw as Record<string, unknown>;
|
||||
if (Object.keys(body).some((key) => !CREATE_GRANT_ALLOWED_KEYS.has(key))) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const subjectUserId = safeParseEkklesiaUserId(body.subjectUserId);
|
||||
if (!subjectUserId.success) {
|
||||
return undefined;
|
||||
}
|
||||
const permission = safeParsePermissionId(body.permission);
|
||||
if (!permission.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let scope: AuthorizationScope | undefined;
|
||||
if (body.scope !== undefined) {
|
||||
const parsedScope = safeParseAuthorizationScope(body.scope);
|
||||
if (!parsedScope.success) {
|
||||
return undefined;
|
||||
}
|
||||
scope = parsedScope.data;
|
||||
}
|
||||
|
||||
let expiresAt: Date | undefined;
|
||||
if (body.expiresAt !== undefined) {
|
||||
if (typeof body.expiresAt !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const parsedDate = new Date(body.expiresAt);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
expiresAt = parsedDate;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
permission: permission.data,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
subjectUserId: subjectUserId.data,
|
||||
});
|
||||
}
|
||||
|
||||
function serializeGrant(grant: GrantRecord) {
|
||||
return {
|
||||
createdAt: grant.createdAt.toISOString(),
|
||||
expiresAt: grant.expiresAt ? grant.expiresAt.toISOString() : null,
|
||||
id: grant.id,
|
||||
permission: grant.permission,
|
||||
revokedAt: grant.revokedAt ? grant.revokedAt.toISOString() : null,
|
||||
scope: grant.scope ?? null,
|
||||
sourceKind: grant.sourceKind,
|
||||
userId: grant.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v1 explicit-grant management API: list, create, and revoke *explicit* grants only. Every
|
||||
* route requires `core.authorization.grants.manage`; a platform owner passes through the
|
||||
* evaluator's existing owner bypass without ever needing an explicit grant of this permission
|
||||
* themselves — that bypass is exactly how a platform owner deliberately delegates grant
|
||||
* administration to another account by granting them this one permission.
|
||||
*
|
||||
* Relationship-derived grants are never reachable through this API in either direction: creation
|
||||
* always hard-codes `sourceKind: "explicit"` server-side (the request body cannot supply a
|
||||
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
|
||||
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
|
||||
* those grants remain owned by the module operation that established the relationship.
|
||||
*/
|
||||
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
const routes = new Hono();
|
||||
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
|
||||
const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
|
||||
|
||||
routes.get("/api/v1/grants", requireManagePermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId"));
|
||||
if (!subjectUserId.success) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
try {
|
||||
const grants = await options.store.listGrantsForUser(subjectUserId.data, {
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ grants: grants.map(serializeGrant) }, 200);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
const parsed = parseCreateGrantBody(rawBody);
|
||||
if (!parsed) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
|
||||
// A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry
|
||||
// (never the request) decides what may be granted — the same registry `requireManagePermission`
|
||||
// already consults for the caller's own `core.authorization.grants.manage` check — so a caller
|
||||
// cannot pre-create a dormant grant for an invented identifier that later becomes effective the
|
||||
// moment someone registers it. Sanitized identically to any other denial: this must never reveal
|
||||
// whether the identifier was malformed (400, above) or simply unregistered (403, here).
|
||||
if (!options.registry.has(parsed.permission)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const grant = await options.store.createGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
||||
permission: parsed.permission,
|
||||
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
|
||||
sourceKind: "explicit",
|
||||
userId: parsed.subjectUserId,
|
||||
});
|
||||
return c.json({ grant: serializeGrant(grant) }, 201);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
});
|
||||
|
||||
routes.post(
|
||||
"/api/v1/grants/:grantId/revoke",
|
||||
requireOrigin,
|
||||
requireManagePermission,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
|
||||
}
|
||||
|
||||
const grantId = safeParseGrantId(c.req.param("grantId"));
|
||||
if (!grantId.success) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await options.store.revokeGrant({
|
||||
actor: createUserActor(principal.userId),
|
||||
grantId: grantId.data,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
return c.json({ success: true }, 200);
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return routes;
|
||||
}
|
||||
+27
-14
@@ -1,15 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import {
|
||||
createIdentityDrizzleDatabase,
|
||||
createIdentityRuntime,
|
||||
type IdentityDatabaseHandle,
|
||||
type IdentityRateLimitOverride,
|
||||
} from "@ekklesia/core/identity";
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity";
|
||||
import { serve, type ServerType } from "@hono/node-server";
|
||||
import type { Hono } from "hono";
|
||||
import { createApiApp } from "./app.js";
|
||||
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
|
||||
|
||||
const connectionTimeoutMs = 10_000;
|
||||
const operationTimeoutMs = 10_000;
|
||||
@@ -26,7 +26,7 @@ export interface StartApiOptions {
|
||||
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||
}
|
||||
|
||||
async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise<void> {
|
||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
try {
|
||||
await handle.close();
|
||||
} catch {
|
||||
@@ -54,34 +54,47 @@ function bindServer(app: Hono, host: string, port: number): Promise<ServerType>
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit runtime composition: constructs the PostgreSQL pool and Drizzle database, the Core
|
||||
* identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle
|
||||
* with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
||||
* server and close the database pool deterministically. Never applies migrations or generates
|
||||
* schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||
* Explicit runtime composition: constructs the one shared PostgreSQL pool (identity and
|
||||
* authorization each get a typed Drizzle view over it — see `./database.js`), the Core identity
|
||||
* runtime, the Core authorization store and evaluator, and the Hono application, then starts the
|
||||
* Node HTTP server. Returns a handle with an idempotent `shutdown()` so both the executable entry
|
||||
* point and tests can stop the server and close the shared pool deterministically. Never applies
|
||||
* migrations or generates schema — the database must already be migrated through
|
||||
* `pnpm migrations:apply`.
|
||||
*/
|
||||
export async function startApi(
|
||||
config: ApiRuntimeConfig,
|
||||
options: StartApiOptions = {},
|
||||
): Promise<RunningApi> {
|
||||
const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, {
|
||||
const databaseHandle = createApiDatabase(config.databaseUrl, {
|
||||
connectionTimeoutMs,
|
||||
operationTimeoutMs,
|
||||
});
|
||||
|
||||
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
try {
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.identity,
|
||||
db: databaseHandle.db,
|
||||
db: databaseHandle.identity,
|
||||
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||
});
|
||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||
} catch (error) {
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const app = createApiApp({ runtime, trustedOrigins: config.identity.trustedOrigins });
|
||||
const evaluator = createAuthorizationEvaluator({
|
||||
grants: authorizationStore,
|
||||
platformOwners: authorizationStore,
|
||||
});
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, store: authorizationStore },
|
||||
runtime,
|
||||
trustedOrigins: config.identity.trustedOrigins,
|
||||
});
|
||||
|
||||
let server: ServerType;
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
@@ -174,13 +180,18 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
// Phase 4A's committed migration must not have drifted while building this transport slice.
|
||||
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
|
||||
// this transport slice.
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
);
|
||||
expect(aggregate.migrations[1]?.checksum).toBe(
|
||||
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
|
||||
);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
@@ -807,3 +818,332 @@ 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";
|
||||
// Module permission registration does not exist yet, so `core.authorization.grants.manage` —
|
||||
// the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some
|
||||
// registered permission" throughout this scenario's explicit-grant lifecycle.
|
||||
const testPermission = "core.authorization.grants.manage";
|
||||
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
|
||||
// live route rejects it even for a caller who otherwise has full grant-management authority.
|
||||
const unregisteredPermission = "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("rejects a well-formed but unregistered permission even from a caller with full grant-management authority, and writes no grant or audit row", async () => {
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
const grantCountBefore = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
|
||||
[unregisteredPermission],
|
||||
);
|
||||
const auditCountBefore = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
||||
[unregisteredPermission],
|
||||
);
|
||||
expect(grantCountBefore[0]?.count).toBe("0");
|
||||
expect(auditCountBefore[0]?.count).toBe("0");
|
||||
|
||||
const response = await fetch(`${origin}/api/v1/grants`, {
|
||||
body: JSON.stringify({
|
||||
permission: unregisteredPermission,
|
||||
subjectUserId: managerUserId,
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: managerCookie,
|
||||
origin: dashboardOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
|
||||
const grantCountAfter = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
|
||||
[unregisteredPermission],
|
||||
);
|
||||
const auditCountAfter = await safeQuery<{ count: string }>(
|
||||
admin,
|
||||
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
||||
[unregisteredPermission],
|
||||
);
|
||||
expect(grantCountAfter[0]?.count).toBe("0");
|
||||
expect(auditCountAfter[0]?.count).toBe("0");
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+122
-19
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
createAuthenticatedPrincipal,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
AuthorizationStoreError,
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createGrantsRoutes } from "../src/grants-routes.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const manager = createAuthenticatedPrincipal("manager_1");
|
||||
const subjectUserId = parseEkklesiaUserId("subject_1");
|
||||
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
|
||||
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
|
||||
// `store.createGrant`/`store.revokeGrant` can do so without also depending on module permission
|
||||
// registration, which does not exist yet.
|
||||
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
|
||||
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(manager),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationStore {
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly store?: AuthorizationStore;
|
||||
}) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
|
||||
};
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const store = options.store ?? fakeStore();
|
||||
const app = new Hono();
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime,
|
||||
store,
|
||||
trustedOrigins: new Set([trustedOrigin]),
|
||||
}),
|
||||
);
|
||||
return { app, evaluator, runtime, store };
|
||||
}
|
||||
|
||||
describe("GET /api/v1/grants", () => {
|
||||
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
|
||||
const { app, evaluator } = buildApp({ allowed: false });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "restricted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
|
||||
const grants = [
|
||||
{
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
const body = (await response.json()) as { grants: unknown[] };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.grants).toHaveLength(1);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
|
||||
});
|
||||
|
||||
it("rejects a missing or malformed userId query parameter", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants?userId=");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/grants", () => {
|
||||
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
||||
const { app, evaluator } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an untrusted Origin header the same way", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("denies a caller the evaluator does not authorize", async () => {
|
||||
const { app, store } = buildApp({ allowed: false });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
|
||||
const createdGrant = {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission: registeredPermission,
|
||||
scope: parseAuthorizationScope("calendar-event:123"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({
|
||||
permission: registeredPermission,
|
||||
scope: "calendar-event:123",
|
||||
subjectUserId,
|
||||
}),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await response.json()) as { grant: { id: string } };
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.grant.id).toBe(grantId);
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
permission: registeredPermission,
|
||||
scope: "calendar-event:123",
|
||||
sourceKind: "explicit",
|
||||
userId: subjectUserId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"actor",
|
||||
"owner",
|
||||
"isOwner",
|
||||
"sourceKind",
|
||||
"sourceRef",
|
||||
"id",
|
||||
"grantId",
|
||||
"createdAt",
|
||||
"revokedAt",
|
||||
"createdByActorId",
|
||||
"effectivePermissions",
|
||||
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const responses = await Promise.all(
|
||||
[
|
||||
{ permission: "not-namespaced", subjectUserId },
|
||||
{ permission, subjectUserId: "" },
|
||||
{ permission, scope: "", subjectUserId },
|
||||
{ expiresAt: "not-a-date", permission, subjectUserId },
|
||||
].map((body) =>
|
||||
app.request("/api/v1/grants", {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
createGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/grants/:grantId/revoke", () => {
|
||||
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
||||
const { app, evaluator } = buildApp({});
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
|
||||
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ success: true });
|
||||
expect(store.revokeGrant).toHaveBeenCalledWith({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
grantId,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
|
||||
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
|
||||
// grant; this test proves the route always asks for that constraint, and correctly surfaces
|
||||
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
|
||||
// kind.
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
expect(store.revokeGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sourceKind: "explicit" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed grant id", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants/%20/revoke", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,26 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig());
|
||||
|
||||
// Identity (via Better Auth's Drizzle adapter) and authorization both read from the same
|
||||
// running API, yet only one `new Pool(...)` call is ever made — proving the shared-pool
|
||||
// composition in apps/api/src/database.ts never falls back to a second, independent pool.
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
|
||||
await running.shutdown();
|
||||
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
@@ -205,6 +225,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
code: "IDENTITY_CONSTRUCTION_FAILED",
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -219,6 +240,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
|
||||
await expect(startApi(validConfig())).rejects.toThrow();
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/identity/schema.ts",
|
||||
schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
prefix: "index",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
CREATE TABLE "ekklesia_core_authorization_audit" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"actor_type" text NOT NULL,
|
||||
"actor_id" text,
|
||||
"subject_user_id" text NOT NULL,
|
||||
"permission_id" text,
|
||||
"scope" text,
|
||||
"grant_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ekklesia_core_authorization_audit_action_check" CHECK ("ekklesia_core_authorization_audit"."action" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')),
|
||||
CONSTRAINT "ekklesia_core_authorization_audit_actor_type_check" CHECK ("ekklesia_core_authorization_audit"."actor_type" in ('user', 'system')),
|
||||
CONSTRAINT "ekklesia_core_authorization_audit_scope_check" CHECK ("ekklesia_core_authorization_audit"."scope" is null or length("ekklesia_core_authorization_audit"."scope") > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_grant" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"permission_id" text NOT NULL,
|
||||
"scope" text,
|
||||
"source_kind" text NOT NULL,
|
||||
"source_ref" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_by_actor_type" text NOT NULL,
|
||||
"created_by_actor_id" text,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoked_by_actor_type" text,
|
||||
"revoked_by_actor_id" text,
|
||||
CONSTRAINT "ekklesia_core_grant_source_kind_check" CHECK ("ekklesia_core_grant"."source_kind" in ('explicit', 'relationship')),
|
||||
CONSTRAINT "ekklesia_core_grant_created_actor_type_check" CHECK ("ekklesia_core_grant"."created_by_actor_type" in ('user', 'system')),
|
||||
CONSTRAINT "ekklesia_core_grant_revoked_actor_type_check" CHECK ("ekklesia_core_grant"."revoked_by_actor_type" is null or "ekklesia_core_grant"."revoked_by_actor_type" in ('user', 'system'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ekklesia_core_platform_owner" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"granted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"granted_by_actor_type" text NOT NULL,
|
||||
"granted_by_actor_id" text,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"revoked_by_actor_type" text,
|
||||
"revoked_by_actor_id" text,
|
||||
CONSTRAINT "ekklesia_core_platform_owner_granted_actor_type_check" CHECK ("ekklesia_core_platform_owner"."granted_by_actor_type" in ('user', 'system')),
|
||||
CONSTRAINT "ekklesia_core_platform_owner_revoked_actor_type_check" CHECK ("ekklesia_core_platform_owner"."revoked_by_actor_type" is null or "ekklesia_core_platform_owner"."revoked_by_actor_type" in ('user', 'system'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_authorization_audit" ADD CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("subject_user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_grant" ADD CONSTRAINT "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_platform_owner" ADD CONSTRAINT "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_authorization_audit_subject_idx" ON "ekklesia_core_authorization_audit" USING btree ("subject_user_id");--> statement-breakpoint
|
||||
CREATE INDEX "ekklesia_core_grant_user_permission_idx" ON "ekklesia_core_grant" USING btree ("user_id","permission_id");
|
||||
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"id": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
|
||||
"prevId": "42cf39d2-7833-4bf3-9043-4e5303da8a75",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.ekklesia_core_account": {
|
||||
"name": "ekklesia_core_account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_account_user_id_idx": {
|
||||
"name": "ekklesia_core_account_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_account",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_session": {
|
||||
"name": "ekklesia_core_session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_session_user_id_idx": {
|
||||
"name": "ekklesia_core_session_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_session",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_session_token_unique": {
|
||||
"name": "ekklesia_core_session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_user": {
|
||||
"name": "ekklesia_core_user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"ekklesia_core_user_email_unique": {
|
||||
"name": "ekklesia_core_user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_verification": {
|
||||
"name": "ekklesia_core_verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_verification_identifier_idx": {
|
||||
"name": "ekklesia_core_verification_identifier_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "identifier",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_authorization_audit": {
|
||||
"name": "ekklesia_core_authorization_audit",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_type": {
|
||||
"name": "actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"actor_id": {
|
||||
"name": "actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"subject_user_id": {
|
||||
"name": "subject_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"grant_id": {
|
||||
"name": "grant_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_authorization_audit_subject_idx": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "subject_user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_authorization_audit",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["subject_user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_authorization_audit_action_check": {
|
||||
"name": "ekklesia_core_authorization_audit_action_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_actor_type_check": {
|
||||
"name": "ekklesia_core_authorization_audit_actor_type_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_authorization_audit_scope_check": {
|
||||
"name": "ekklesia_core_authorization_audit_scope_check",
|
||||
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_grant": {
|
||||
"name": "ekklesia_core_grant",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"permission_id": {
|
||||
"name": "permission_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_kind": {
|
||||
"name": "source_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"created_by_actor_type": {
|
||||
"name": "created_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_by_actor_id": {
|
||||
"name": "created_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ekklesia_core_grant_user_permission_idx": {
|
||||
"name": "ekklesia_core_grant_user_permission_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "permission_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_grant",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_grant_source_kind_check": {
|
||||
"name": "ekklesia_core_grant_source_kind_check",
|
||||
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
|
||||
},
|
||||
"ekklesia_core_grant_created_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_created_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_grant_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_grant_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.ekklesia_core_platform_owner": {
|
||||
"name": "ekklesia_core_platform_owner",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_at": {
|
||||
"name": "granted_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"granted_by_actor_type": {
|
||||
"name": "granted_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"granted_by_actor_id": {
|
||||
"name": "granted_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_type": {
|
||||
"name": "revoked_by_actor_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"revoked_by_actor_id": {
|
||||
"name": "revoked_by_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
|
||||
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
|
||||
"tableFrom": "ekklesia_core_platform_owner",
|
||||
"tableTo": "ekklesia_core_user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
|
||||
},
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check": {
|
||||
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0001_authorization_core_tables",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1784668552522,
|
||||
"tag": "0000_identity_core_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1784819635300,
|
||||
"tag": "0001_authorization_core_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
"./identity": {
|
||||
"types": "./dist/identity/index.d.ts",
|
||||
"import": "./dist/identity/index.js"
|
||||
},
|
||||
"./authorization": {
|
||||
"types": "./dist/authorization/index.d.ts",
|
||||
"import": "./dist/authorization/index.js"
|
||||
},
|
||||
"./database": {
|
||||
"types": "./dist/database/index.d.ts",
|
||||
"import": "./dist/database/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { EkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js";
|
||||
|
||||
export type PlatformOwnerBootstrapErrorCode =
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_FAILED"
|
||||
| "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND";
|
||||
|
||||
const bootstrapErrorMessages: Readonly<Record<PlatformOwnerBootstrapErrorCode, string>> =
|
||||
Object.freeze({
|
||||
PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED:
|
||||
"Platform-owner bootstrap has already been used and cannot be used again.",
|
||||
PLATFORM_OWNER_BOOTSTRAP_FAILED: "Platform-owner bootstrap could not be completed.",
|
||||
PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND: "The target user does not exist.",
|
||||
});
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||
export class PlatformOwnerBootstrapError extends Error {
|
||||
readonly code: PlatformOwnerBootstrapErrorCode;
|
||||
|
||||
constructor(code: PlatformOwnerBootstrapErrorCode) {
|
||||
super(bootstrapErrorMessages[code]);
|
||||
this.name = "PlatformOwnerBootstrapError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlatformOwnerBootstrapError(error: unknown): error is PlatformOwnerBootstrapError {
|
||||
return error instanceof PlatformOwnerBootstrapError;
|
||||
}
|
||||
|
||||
/** A fixed, sanitized, non-user actor identity for the audit trail — distinguishable from both an
|
||||
* ordinary "system" action with no specific actor and from any real `EkklesiaUserId`, so an
|
||||
* auditor can tell this owner grant came specifically from the bootstrap command. Never a real
|
||||
* user id and never derived from caller input. */
|
||||
const BOOTSTRAP_ACTOR_TYPE = "system";
|
||||
const BOOTSTRAP_ACTOR_ID = "authorization-bootstrap-cli";
|
||||
|
||||
// A dedicated advisory-lock key pair, distinct from `core/src/migrations/postgres-database.ts`'s
|
||||
// migration lock, so bootstrap serialization can never collide with or be blocked by unrelated
|
||||
// migration locking. `pg_advisory_xact_lock` is transaction-scoped: it is released automatically
|
||||
// on COMMIT or ROLLBACK, so no manual unlock is needed even if the transaction fails partway.
|
||||
const bootstrapLockNamespace = 1_500_000_001;
|
||||
const bootstrapLockKey = 1_500_000_002;
|
||||
|
||||
export interface BootstrapPlatformOwnerInput {
|
||||
readonly userId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
|
||||
* composition of "check, then call the existing grant function": the single-use check, the target
|
||||
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
|
||||
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first
|
||||
* statement — so two concurrent callers can never both observe "no owner yet" and both insert.
|
||||
* The second caller blocks on the lock until the first's transaction commits or rolls back, then
|
||||
* re-evaluates against the now-current state.
|
||||
*
|
||||
* "Already consumed" is global and permanent: it is true the moment
|
||||
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose
|
||||
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a
|
||||
* platform owner would ever be re-established after a revocation, not this command.
|
||||
*/
|
||||
export async function bootstrapPlatformOwner(
|
||||
db: AuthorizationDrizzleDatabase,
|
||||
input: BootstrapPlatformOwnerInput,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
|
||||
);
|
||||
|
||||
const existingOwnerRows = await tx
|
||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
||||
.from(ekklesiaCorePlatformOwner)
|
||||
.limit(1);
|
||||
if (existingOwnerRows.length > 0) {
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
|
||||
}
|
||||
|
||||
const targetUserRows = await tx
|
||||
.select({ id: ekklesiaCoreUser.id })
|
||||
.from(ekklesiaCoreUser)
|
||||
.where(eq(ekklesiaCoreUser.id, input.userId))
|
||||
.limit(1);
|
||||
if (targetUserRows.length === 0) {
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCorePlatformOwner).values({
|
||||
grantedByActorId: BOOTSTRAP_ACTOR_ID,
|
||||
grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "platform_owner.grant",
|
||||
actorId: BOOTSTRAP_ACTOR_ID,
|
||||
actorType: BOOTSTRAP_ACTOR_TYPE,
|
||||
subjectUserId: input.userId,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPlatformOwnerBootstrapError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { Pool, type PoolConfig } from "pg";
|
||||
import { authorizationSchema } from "./schema.js";
|
||||
|
||||
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
|
||||
|
||||
export type AuthorizationDatabaseErrorCode = "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID";
|
||||
|
||||
const maximumPostgresTimeoutMs = 2_147_483_647;
|
||||
|
||||
/** Never carries the offending connection string, host, or underlying driver error. */
|
||||
export class AuthorizationDatabaseError extends Error {
|
||||
readonly code: AuthorizationDatabaseErrorCode;
|
||||
|
||||
constructor(code: AuthorizationDatabaseErrorCode) {
|
||||
super("Authorization database configuration is invalid.");
|
||||
this.name = "AuthorizationDatabaseError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthorizationDatabaseOptions {
|
||||
readonly connectionTimeoutMs?: number;
|
||||
/** Bounds both `query_timeout` and `statement_timeout`, mirroring
|
||||
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
|
||||
* connection, not a hung query already running on one. */
|
||||
readonly operationTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AuthorizationDatabaseHandle {
|
||||
readonly close: () => Promise<void>;
|
||||
readonly db: AuthorizationDrizzleDatabase;
|
||||
}
|
||||
|
||||
function validateConnectionString(connectionString: string): string {
|
||||
if (connectionString.trim().length === 0) {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
let protocol: string;
|
||||
try {
|
||||
protocol = new URL(connectionString).protocol;
|
||||
} catch {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
/** Mirrors `core/src/identity/database.ts`'s timeout validation exactly: an explicit timeout must
|
||||
* be a positive safe integer no larger than PostgreSQL's own maximum, or it is rejected as invalid
|
||||
* configuration rather than silently passed through to `pg`. */
|
||||
function validateTimeout(value: unknown): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value <= 0 ||
|
||||
value > maximumPostgresTimeoutMs
|
||||
) {
|
||||
throw new AuthorizationDatabaseError("AUTHORIZATION_DATABASE_CONFIGURATION_INVALID");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly constructs the Core-owned Drizzle database instance backing the authorization store.
|
||||
* Building the `pg.Pool` performs no network I/O until a query is issued, and this function is
|
||||
* only ever invoked by explicit caller code — never during module import.
|
||||
*/
|
||||
export function createAuthorizationDrizzleDatabase(
|
||||
connectionString: string,
|
||||
options: AuthorizationDatabaseOptions = {},
|
||||
): AuthorizationDatabaseHandle {
|
||||
const validatedConnectionString = validateConnectionString(connectionString);
|
||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||
const poolConfig: PoolConfig = {
|
||||
connectionString: validatedConnectionString,
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
...(validatedOperationTimeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
query_timeout: validatedOperationTimeoutMs,
|
||||
statement_timeout: validatedOperationTimeoutMs,
|
||||
}),
|
||||
};
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
|
||||
// listener on the pool, or a server-initiated termination of an idle client becomes an
|
||||
// unhandled Node.js error and can crash the process.
|
||||
pool.on("error", () => undefined);
|
||||
const db = drizzle(pool, { schema: authorizationSchema });
|
||||
|
||||
return Object.freeze({
|
||||
close: () => pool.end(),
|
||||
db,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed Drizzle view over an externally owned pool — for example one shared with the identity
|
||||
* schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
|
||||
* `createAuthorizationDrizzleDatabase`, this function never constructs, registers an error
|
||||
* listener on, or closes a pool itself: the caller retains that responsibility exactly once,
|
||||
* regardless of how many typed views it creates over the same pool.
|
||||
*/
|
||||
export function createAuthorizationDrizzleView(pool: Pool): AuthorizationDrizzleDatabase {
|
||||
return drizzle(pool, { schema: authorizationSchema });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
AUTHORIZATION_ALLOWED,
|
||||
AUTHORIZATION_DENIED,
|
||||
type AuthorizationDecision,
|
||||
type AuthorizationScope,
|
||||
type EkklesiaPrincipal,
|
||||
isAuthenticatedPrincipal,
|
||||
permissionBaselineSchema,
|
||||
safeParseAuthorizationScope,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js";
|
||||
|
||||
export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED";
|
||||
|
||||
/** Never carries the underlying port error, database error, cause, stack, or any other internal
|
||||
* detail — defense in depth on top of whatever sanitization the injected ports themselves already
|
||||
* perform (`core/src/authorization/store.ts` already sanitizes), exactly like
|
||||
* `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own
|
||||
* error handling rather than trusting it exclusively. */
|
||||
export class AuthorizationEvaluationError extends Error {
|
||||
readonly code: AuthorizationEvaluationErrorCode;
|
||||
|
||||
constructor() {
|
||||
super("The authorization decision could not be evaluated.");
|
||||
this.name = "AuthorizationEvaluationError";
|
||||
this.code = "AUTHORIZATION_EVALUATION_FAILED";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthorizeRequest {
|
||||
readonly principal: EkklesiaPrincipal;
|
||||
/** Validated internally via the branded `PermissionId` schema *before any baseline can return an
|
||||
* allowed decision* — a malformed or unrecognizable value denies rather than throwing, for
|
||||
* `public` and `authenticated` baselines exactly as much as `restricted`. This is shape
|
||||
* validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered
|
||||
* permission from one that is actually registered. Only a trusted caller consulting a
|
||||
* `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can
|
||||
* make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */
|
||||
readonly permission: unknown;
|
||||
/** Validated internally against the known `public`/`authenticated`/`restricted` set; anything
|
||||
* else denies rather than throwing, for the same fail-closed reason. */
|
||||
readonly baseline: unknown;
|
||||
/** Optional; validated internally when present. Absent means an unscoped check. */
|
||||
readonly scope?: unknown;
|
||||
}
|
||||
|
||||
export interface AuthorizationEvaluator {
|
||||
authorize(request: AuthorizeRequest): Promise<AuthorizationDecision>;
|
||||
}
|
||||
|
||||
export interface CreateAuthorizationEvaluatorOptions {
|
||||
readonly grants: AuthorizationGrantPort;
|
||||
readonly platformOwners: AuthorizationPlatformOwnerPort;
|
||||
/** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the
|
||||
* real wall clock; production composition must leave this unset. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Hono-independent, deny-by-default authorization evaluator. It is the only place
|
||||
* Ekklesia decides whether a principal may exercise a permission, and it never trusts anything
|
||||
* the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a
|
||||
* caller to assert "I am the owner" or "I already have this grant." Owner and grant state are
|
||||
* always re-derived from the injected ports for every call.
|
||||
*
|
||||
* A genuine infrastructure failure from either port (for example a database outage) is
|
||||
* deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a
|
||||
* thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity
|
||||
* `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely
|
||||
* malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem,
|
||||
* not an infrastructure failure — resolves to `denied` without throwing.
|
||||
*/
|
||||
export function createAuthorizationEvaluator(
|
||||
options: CreateAuthorizationEvaluatorOptions,
|
||||
): AuthorizationEvaluator {
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
return Object.freeze({
|
||||
authorize: async (request: AuthorizeRequest): Promise<AuthorizationDecision> => {
|
||||
const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline);
|
||||
if (!parsedBaseline.success) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
// Shape-validated before any baseline is allowed to return `allowed` — a malformed
|
||||
// permission id must deny under `public` and `authenticated` exactly as much as under
|
||||
// `restricted`, even though neither of the first two ever reads `parsedPermission.data`.
|
||||
// This is a pure, synchronous check: it never touches the grant or platform-owner ports, so
|
||||
// `public`/`authenticated` still resolve with no database lookup at all.
|
||||
const parsedPermission = safeParsePermissionId(request.permission);
|
||||
if (!parsedPermission.success) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
if (parsedBaseline.data === "public") {
|
||||
return AUTHORIZATION_ALLOWED;
|
||||
}
|
||||
|
||||
if (parsedBaseline.data === "authenticated") {
|
||||
return isAuthenticatedPrincipal(request.principal)
|
||||
? AUTHORIZATION_ALLOWED
|
||||
: AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
// parsedBaseline.data === "restricted": requires an authenticated principal AND
|
||||
// (an active matching grant OR platform-owner authority). Baseline alone never satisfies it.
|
||||
if (!isAuthenticatedPrincipal(request.principal)) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
|
||||
let scope: AuthorizationScope | undefined;
|
||||
if (request.scope !== undefined) {
|
||||
const parsedScope = safeParseAuthorizationScope(request.scope);
|
||||
if (!parsedScope.success) {
|
||||
return AUTHORIZATION_DENIED;
|
||||
}
|
||||
scope = parsedScope.data;
|
||||
}
|
||||
|
||||
const userId = request.principal.userId;
|
||||
let isOwner: boolean;
|
||||
let hasGrant: boolean;
|
||||
try {
|
||||
[isOwner, hasGrant] = await Promise.all([
|
||||
options.platformOwners.isPlatformOwner(userId),
|
||||
options.grants.hasActiveGrant({
|
||||
now: now(),
|
||||
permission: parsedPermission.data,
|
||||
userId,
|
||||
...(scope === undefined ? {} : { scope }),
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new AuthorizationEvaluationError();
|
||||
}
|
||||
|
||||
return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./bootstrap.js";
|
||||
export * from "./database.js";
|
||||
export * from "./evaluator.js";
|
||||
export * from "./permissions.js";
|
||||
export * from "./ports.js";
|
||||
export * from "./store.js";
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
type PermissionBaseline,
|
||||
type PermissionId,
|
||||
parsePermissionId,
|
||||
safeParsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
|
||||
/**
|
||||
* A registered permission declaration: the trusted pairing of a permission identifier with its
|
||||
* one baseline. This is what distinguishes a *registered* permission from a merely well-formed
|
||||
* `PermissionId` string — the evaluator itself (`createAuthorizationEvaluator`) still takes an
|
||||
* explicit, caller-supplied baseline per pass 1's design; the registry is what a trusted caller
|
||||
* (Core's own HTTP enforcement, eventually a module registration mechanism) consults so that
|
||||
* baseline is never client-supplied or guessed.
|
||||
*/
|
||||
export interface PermissionDeclaration {
|
||||
readonly id: PermissionId;
|
||||
readonly baseline: PermissionBaseline;
|
||||
/** Human-readable, for operators and audit review only — never interpreted by the evaluator or
|
||||
* by enforcement. */
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface PermissionRegistry {
|
||||
readonly declarations: readonly PermissionDeclaration[];
|
||||
/** Returns the declaration for a permission id, or `undefined` for anything malformed or not
|
||||
* registered — the two are deliberately indistinguishable to callers, so "unknown permission"
|
||||
* and "invalid permission" fail closed the same way. */
|
||||
get(permissionId: unknown): PermissionDeclaration | undefined;
|
||||
has(permissionId: unknown): boolean;
|
||||
}
|
||||
|
||||
export type PermissionRegistryErrorCode = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
||||
|
||||
/** Thrown only at composition time (registry construction), never per-request. Carries the
|
||||
* offending permission id deliberately — this is a startup-time programmer/configuration error
|
||||
* message, not a runtime response, and a namespaced permission id is not sensitive. */
|
||||
export class PermissionRegistryError extends Error {
|
||||
readonly code: PermissionRegistryErrorCode;
|
||||
readonly permissionId: string;
|
||||
|
||||
constructor(permissionId: string) {
|
||||
super(`Permission "${permissionId}" is already registered.`);
|
||||
this.name = "PermissionRegistryError";
|
||||
this.code = "PERMISSION_REGISTRY_DUPLICATE_DECLARATION";
|
||||
this.permissionId = permissionId;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an immutable permission registry from a fixed list of declarations, rejecting any
|
||||
* duplicate permission id outright at composition time. Deliberately the smallest mechanism that
|
||||
* distinguishes "registered" from "well-formed": no module discovery, no dynamic registration, no
|
||||
* network or filesystem I/O — a future module registration mechanism can merge its own
|
||||
* declarations into a combined list and still call this same constructor, inheriting duplicate
|
||||
* detection for free instead of reimplementing it.
|
||||
*/
|
||||
export function createPermissionRegistry(
|
||||
declarations: readonly PermissionDeclaration[],
|
||||
): PermissionRegistry {
|
||||
const byId = new Map<PermissionId, PermissionDeclaration>();
|
||||
for (const declaration of declarations) {
|
||||
if (byId.has(declaration.id)) {
|
||||
throw new PermissionRegistryError(declaration.id);
|
||||
}
|
||||
byId.set(declaration.id, Object.freeze({ ...declaration }));
|
||||
}
|
||||
const frozenDeclarations = Object.freeze([...byId.values()]);
|
||||
|
||||
return Object.freeze({
|
||||
declarations: frozenDeclarations,
|
||||
get(permissionId: unknown): PermissionDeclaration | undefined {
|
||||
const parsed = safeParsePermissionId(permissionId);
|
||||
return parsed.success ? byId.get(parsed.data) : undefined;
|
||||
},
|
||||
has(permissionId: unknown): boolean {
|
||||
const parsed = safeParsePermissionId(permissionId);
|
||||
return parsed.success && byId.has(parsed.data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Restricted: delegated grant administration is itself a privileged action, never public or
|
||||
* merely authenticated. A platform owner always passes the evaluator's existing owner bypass and
|
||||
* can delegate this permission to another account deliberately via the explicit-grant API. */
|
||||
export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId(
|
||||
"core.authorization.grants.manage",
|
||||
);
|
||||
|
||||
const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Object.freeze([
|
||||
Object.freeze({
|
||||
baseline: "restricted" as const,
|
||||
description: "Create, list, and revoke another user's explicit authorization grants.",
|
||||
id: CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Core's own permission registry — the trusted source of truth `apps/api`'s authorization
|
||||
* enforcement consults so an HTTP route can never supply its own baseline. Contains exactly the
|
||||
* one Core-owned permission this slice needs; a future module registration mechanism is
|
||||
* deliberately out of scope here and will compose its own declarations through
|
||||
* `createPermissionRegistry` rather than through this constant.
|
||||
*/
|
||||
export const CORE_PERMISSION_REGISTRY: PermissionRegistry = createPermissionRegistry(
|
||||
CORE_PERMISSION_DECLARATIONS,
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts";
|
||||
|
||||
export interface ActiveGrantQuery {
|
||||
readonly userId: EkklesiaUserId;
|
||||
readonly permission: PermissionId;
|
||||
/** Absent means an unscoped check. An unscoped grant satisfies either an unscoped or scoped
|
||||
* check for the same permission; a scoped grant satisfies only the exact matching scope and
|
||||
* never satisfies an unscoped check. */
|
||||
readonly scope?: AuthorizationScope;
|
||||
/** The instant to evaluate expiration against — injected so evaluation is deterministic and
|
||||
* testable rather than reading the wall clock inside the lookup itself. */
|
||||
readonly now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The only read dependency `createAuthorizationEvaluator` has for grant state. Narrow and
|
||||
* Postgres-independent by design: a test can supply a fake implementation, and the evaluator
|
||||
* itself never knows whether it is backed by `core/src/authorization/store.ts` or a test double.
|
||||
*/
|
||||
export interface AuthorizationGrantPort {
|
||||
hasActiveGrant(query: ActiveGrantQuery): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** The only read dependency the evaluator has for platform-owner authority — deliberately a
|
||||
* separate, narrower port from grants, since owner authority is not represented as a grant. */
|
||||
export interface AuthorizationPlatformOwnerPort {
|
||||
isPlatformOwner(userId: EkklesiaUserId): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { check, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { ekklesiaCoreUser } from "../identity/schema.js";
|
||||
|
||||
/**
|
||||
* Core-owned authorization schema: grants (explicit or relationship-derived), platform-owner
|
||||
* status, and an append-only audit log of every security-relevant mutation. Table and index names
|
||||
* are explicit and namespaced so ownership is unambiguous inside the shared public PostgreSQL
|
||||
* schema, exactly like `core/src/identity/schema.ts`. Foreign keys to `ekklesia_core_user` are
|
||||
* safe here (unlike a module referencing another module's tables) because Core owns both sides.
|
||||
*/
|
||||
|
||||
export const ekklesiaCoreGrant = pgTable(
|
||||
"ekklesia_core_grant",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
permissionId: text("permission_id").notNull(),
|
||||
scope: text("scope"),
|
||||
sourceKind: text("source_kind").notNull(),
|
||||
sourceRef: text("source_ref"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
createdByActorType: text("created_by_actor_type").notNull(),
|
||||
createdByActorId: text("created_by_actor_id"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
revokedByActorType: text("revoked_by_actor_type"),
|
||||
revokedByActorId: text("revoked_by_actor_id"),
|
||||
},
|
||||
(table) => [
|
||||
index("ekklesia_core_grant_user_permission_idx").on(table.userId, table.permissionId),
|
||||
check(
|
||||
"ekklesia_core_grant_source_kind_check",
|
||||
sql`${table.sourceKind} in ('explicit', 'relationship')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_grant_created_actor_type_check",
|
||||
sql`${table.createdByActorType} in ('user', 'system')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_grant_revoked_actor_type_check",
|
||||
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const ekklesiaCorePlatformOwner = pgTable(
|
||||
"ekklesia_core_platform_owner",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.primaryKey()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
grantedByActorType: text("granted_by_actor_type").notNull(),
|
||||
grantedByActorId: text("granted_by_actor_id"),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
revokedByActorType: text("revoked_by_actor_type"),
|
||||
revokedByActorId: text("revoked_by_actor_id"),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
"ekklesia_core_platform_owner_granted_actor_type_check",
|
||||
sql`${table.grantedByActorType} in ('user', 'system')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_platform_owner_revoked_actor_type_check",
|
||||
sql`${table.revokedByActorType} is null or ${table.revokedByActorType} in ('user', 'system')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope`
|
||||
* intentionally rejects an empty (but non-null) string — defense in depth matching the
|
||||
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect
|
||||
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction
|
||||
* atomically instead of recording a meaningless audit entry.
|
||||
*/
|
||||
export const ekklesiaCoreAuthorizationAudit = pgTable(
|
||||
"ekklesia_core_authorization_audit",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
action: text("action").notNull(),
|
||||
actorType: text("actor_type").notNull(),
|
||||
actorId: text("actor_id"),
|
||||
subjectUserId: text("subject_user_id")
|
||||
.notNull()
|
||||
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
|
||||
permissionId: text("permission_id"),
|
||||
scope: text("scope"),
|
||||
grantId: uuid("grant_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("ekklesia_core_authorization_audit_subject_idx").on(table.subjectUserId),
|
||||
check(
|
||||
"ekklesia_core_authorization_audit_action_check",
|
||||
sql`${table.action} in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_authorization_audit_actor_type_check",
|
||||
sql`${table.actorType} in ('user', 'system')`,
|
||||
),
|
||||
check(
|
||||
"ekklesia_core_authorization_audit_scope_check",
|
||||
sql`${table.scope} is null or length(${table.scope}) > 0`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
|
||||
user: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCoreGrant.userId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const ekklesiaCorePlatformOwnerRelations = relations(
|
||||
ekklesiaCorePlatformOwner,
|
||||
({ one }) => ({
|
||||
user: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCorePlatformOwner.userId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ekklesiaCoreAuthorizationAuditRelations = relations(
|
||||
ekklesiaCoreAuthorizationAudit,
|
||||
({ one }) => ({
|
||||
subject: one(ekklesiaCoreUser, {
|
||||
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
|
||||
references: [ekklesiaCoreUser.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
|
||||
export const authorizationSchema = {
|
||||
authorizationAudit: ekklesiaCoreAuthorizationAudit,
|
||||
grant: ekklesiaCoreGrant,
|
||||
platformOwner: ekklesiaCorePlatformOwner,
|
||||
} as const;
|
||||
@@ -0,0 +1,389 @@
|
||||
import type {
|
||||
AuthorizationActor,
|
||||
AuthorizationScope,
|
||||
EkklesiaUserId,
|
||||
GrantId,
|
||||
GrantSourceKind,
|
||||
GrantSourceRef,
|
||||
PermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
isUserActor,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
|
||||
import type { AuthorizationDrizzleDatabase } from "./database.js";
|
||||
import type {
|
||||
ActiveGrantQuery,
|
||||
AuthorizationGrantPort,
|
||||
AuthorizationPlatformOwnerPort,
|
||||
} from "./ports.js";
|
||||
import {
|
||||
ekklesiaCoreAuthorizationAudit,
|
||||
ekklesiaCoreGrant,
|
||||
ekklesiaCorePlatformOwner,
|
||||
} from "./schema.js";
|
||||
|
||||
export type AuthorizationStoreErrorCode =
|
||||
| "AUTHORIZATION_GRANT_CREATE_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LIST_FAILED"
|
||||
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_GRANT_NOT_FOUND"
|
||||
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED"
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED"
|
||||
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
|
||||
|
||||
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
|
||||
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
|
||||
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
|
||||
AUTHORIZATION_GRANT_REVOKE_FAILED: "The authorization grant could not be revoked.",
|
||||
AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED: "The platform-owner lookup could not be completed.",
|
||||
AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED: "The platform-owner change could not be recorded.",
|
||||
AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND:
|
||||
"The platform-owner record was not found or is already revoked.",
|
||||
});
|
||||
|
||||
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */
|
||||
export class AuthorizationStoreError extends Error {
|
||||
readonly code: AuthorizationStoreErrorCode;
|
||||
|
||||
constructor(code: AuthorizationStoreErrorCode) {
|
||||
super(storeErrorMessages[code]);
|
||||
this.name = "AuthorizationStoreError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorizationStoreError(error: unknown): error is AuthorizationStoreError {
|
||||
return error instanceof AuthorizationStoreError;
|
||||
}
|
||||
|
||||
export interface CreateGrantInput {
|
||||
readonly userId: EkklesiaUserId;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly sourceKind: GrantSourceKind;
|
||||
readonly sourceRef?: GrantSourceRef;
|
||||
readonly actor: AuthorizationActor;
|
||||
/** Absent means the grant never expires. */
|
||||
readonly expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface GrantRecord {
|
||||
readonly id: GrantId;
|
||||
readonly userId: EkklesiaUserId;
|
||||
readonly permission: PermissionId;
|
||||
readonly scope?: AuthorizationScope;
|
||||
readonly sourceKind: GrantSourceKind;
|
||||
readonly sourceRef?: GrantSourceRef;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt?: Date;
|
||||
readonly revokedAt?: Date;
|
||||
}
|
||||
|
||||
export interface RevokeGrantInput {
|
||||
readonly grantId: GrantId;
|
||||
readonly actor: AuthorizationActor;
|
||||
/** When provided, the revoke only matches a grant whose `sourceKind` equals this value — a
|
||||
* grant of a different source kind is treated exactly like a nonexistent one
|
||||
* (`AUTHORIZATION_GRANT_NOT_FOUND`). The generic explicit-grant HTTP API always passes
|
||||
* `"explicit"` here, so it can never revoke a relationship-derived grant regardless of caller
|
||||
* intent; internal Core callers that omit this may revoke any grant, matching pass 1's
|
||||
* original, unconstrained behavior. */
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
export interface ListGrantsForUserOptions {
|
||||
/** When provided, only grants with this exact `sourceKind` are returned. */
|
||||
readonly sourceKind?: GrantSourceKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
|
||||
* append-only audit log, all behind one explicit factory — no environment reads, no connection
|
||||
* attempt, and no query merely from importing this module or calling this factory; every method
|
||||
* call is the only thing that ever touches PostgreSQL. Implements the narrow read ports
|
||||
* `createAuthorizationEvaluator` depends on directly, so the same store instance is both Core's
|
||||
* write API and the evaluator's read dependency.
|
||||
*/
|
||||
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
|
||||
createGrant(input: CreateGrantInput): Promise<GrantRecord>;
|
||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides
|
||||
* what to display. Never includes another user's grants. */
|
||||
listGrantsForUser(
|
||||
userId: EkklesiaUserId,
|
||||
options?: ListGrantsForUserOptions,
|
||||
): Promise<readonly GrantRecord[]>;
|
||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
}
|
||||
|
||||
function actorId(actor: AuthorizationActor): string | null {
|
||||
return isUserActor(actor) ? actor.userId : null;
|
||||
}
|
||||
|
||||
interface GrantRow {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly permissionId: string;
|
||||
readonly scope: string | null;
|
||||
readonly sourceKind: string;
|
||||
readonly sourceRef: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly expiresAt: Date | null;
|
||||
readonly revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Re-validates a row this same store just wrote, rather than trusting raw column values by
|
||||
* construction — defense in depth against a future schema/migration drift, matching the
|
||||
* paranoid-by-default posture the rest of Core's persistence code already follows. */
|
||||
function toGrantRecord(row: GrantRow): GrantRecord {
|
||||
return Object.freeze({
|
||||
createdAt: row.createdAt,
|
||||
id: parseGrantId(row.id),
|
||||
permission: parsePermissionId(row.permissionId),
|
||||
...(row.scope === null ? {} : { scope: parseAuthorizationScope(row.scope) }),
|
||||
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
|
||||
...(row.revokedAt === null ? {} : { revokedAt: row.revokedAt }),
|
||||
sourceKind: row.sourceKind as GrantSourceKind,
|
||||
...(row.sourceRef === null ? {} : { sourceRef: row.sourceRef }),
|
||||
userId: parseEkklesiaUserId(row.userId),
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
|
||||
return Object.freeze({
|
||||
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(ekklesiaCoreGrant)
|
||||
.values({
|
||||
createdByActorId: actorId(input.actor),
|
||||
createdByActorType: input.actor.type,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
userId: input.userId,
|
||||
})
|
||||
.returning();
|
||||
if (!row) {
|
||||
throw new Error("grant insert returned no row");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "grant.create",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
subjectUserId: input.userId,
|
||||
});
|
||||
|
||||
return toGrantRecord(row);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(ekklesiaCoreGrant)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(input.actor),
|
||||
revokedByActorType: input.actor.type,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.id, input.grantId),
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
...(input.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "grant.revoke",
|
||||
actorId: actorId(input.actor),
|
||||
actorType: input.actor.type,
|
||||
grantId: row.id,
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
hasActiveGrant: async (query: ActiveGrantQuery): Promise<boolean> => {
|
||||
try {
|
||||
const scopeCondition =
|
||||
query.scope === undefined
|
||||
? isNull(ekklesiaCoreGrant.scope)
|
||||
: or(isNull(ekklesiaCoreGrant.scope), eq(ekklesiaCoreGrant.scope, query.scope));
|
||||
const rows = await db
|
||||
.select({ id: ekklesiaCoreGrant.id })
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.userId, query.userId),
|
||||
eq(ekklesiaCoreGrant.permissionId, query.permission),
|
||||
scopeCondition,
|
||||
isNull(ekklesiaCoreGrant.revokedAt),
|
||||
or(isNull(ekklesiaCoreGrant.expiresAt), gt(ekklesiaCoreGrant.expiresAt, query.now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LOOKUP_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
listGrantsForUser: async (
|
||||
userId: EkklesiaUserId,
|
||||
options: ListGrantsForUserOptions = {},
|
||||
): Promise<readonly GrantRecord[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(ekklesiaCoreGrant)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCoreGrant.userId, userId),
|
||||
...(options.sourceKind === undefined
|
||||
? []
|
||||
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(ekklesiaCoreGrant.createdAt));
|
||||
return Object.freeze(rows.map(toGrantRecord));
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
grantPlatformOwner: async (
|
||||
userId: EkklesiaUserId,
|
||||
actor: AuthorizationActor,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.insert(ekklesiaCorePlatformOwner)
|
||||
.values({
|
||||
grantedByActorId: actorId(actor),
|
||||
grantedByActorType: actor.type,
|
||||
userId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
grantedAt: sql`now()`,
|
||||
grantedByActorId: actorId(actor),
|
||||
grantedByActorType: actor.type,
|
||||
revokedAt: null,
|
||||
revokedByActorId: null,
|
||||
revokedByActorType: null,
|
||||
},
|
||||
target: ekklesiaCorePlatformOwner.userId,
|
||||
});
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "platform_owner.grant",
|
||||
actorId: actorId(actor),
|
||||
actorType: actor.type,
|
||||
subjectUserId: userId,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
revokePlatformOwner: async (
|
||||
userId: EkklesiaUserId,
|
||||
actor: AuthorizationActor,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.update(ekklesiaCorePlatformOwner)
|
||||
.set({
|
||||
revokedAt: sql`now()`,
|
||||
revokedByActorId: actorId(actor),
|
||||
revokedByActorType: actor.type,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCorePlatformOwner.userId, userId),
|
||||
isNull(ekklesiaCorePlatformOwner.revokedAt),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (!row) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND");
|
||||
}
|
||||
|
||||
await tx.insert(ekklesiaCoreAuthorizationAudit).values({
|
||||
action: "platform_owner.revoke",
|
||||
actorId: actorId(actor),
|
||||
actorType: actor.type,
|
||||
subjectUserId: userId,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED");
|
||||
}
|
||||
},
|
||||
|
||||
isPlatformOwner: async (userId: EkklesiaUserId): Promise<boolean> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ userId: ekklesiaCorePlatformOwner.userId })
|
||||
.from(ekklesiaCorePlatformOwner)
|
||||
.where(
|
||||
and(
|
||||
eq(ekklesiaCorePlatformOwner.userId, userId),
|
||||
isNull(ekklesiaCorePlatformOwner.revokedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows.length > 0;
|
||||
} catch {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { safeParseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../authorization/index.js";
|
||||
|
||||
class BootstrapCliError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "BootstrapCliError";
|
||||
}
|
||||
}
|
||||
|
||||
const usage =
|
||||
"Usage: pnpm authorization:bootstrap-owner -- --user-id <id> --confirm-user-id <same-id>";
|
||||
|
||||
interface ParsedArgs {
|
||||
readonly confirmUserId: string;
|
||||
readonly userId: string;
|
||||
}
|
||||
|
||||
/** A minimal, hand-rolled flag parser — this command takes exactly two required, named flags, so
|
||||
* a general-purpose argument-parsing dependency is not warranted. */
|
||||
function parseArgs(argv: readonly string[]): ParsedArgs {
|
||||
let userId: string | undefined;
|
||||
let confirmUserId: string | undefined;
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const flag = argv[index];
|
||||
if (flag === "--user-id") {
|
||||
userId = argv[index + 1];
|
||||
index += 1;
|
||||
} else if (flag === "--confirm-user-id") {
|
||||
confirmUserId = argv[index + 1];
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if (userId === undefined || confirmUserId === undefined) {
|
||||
throw new BootstrapCliError(usage);
|
||||
}
|
||||
return { confirmUserId, userId };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { confirmUserId, userId: rawUserId } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Deliberately not "infer the first user" or "select by signup order" — the operator must name
|
||||
// the exact, opaque Ekklesia user id twice, and both must match exactly, before anything else
|
||||
// is even attempted.
|
||||
if (rawUserId !== confirmUserId) {
|
||||
throw new BootstrapCliError(
|
||||
"--user-id and --confirm-user-id must match exactly. No changes were made.",
|
||||
);
|
||||
}
|
||||
|
||||
const parsedUserId = safeParseEkklesiaUserId(rawUserId);
|
||||
if (!parsedUserId.success) {
|
||||
throw new BootstrapCliError("The provided user id is not a valid Ekklesia user id.");
|
||||
}
|
||||
|
||||
// Read only when this command actually executes — never merely by importing this module (see
|
||||
// isDirectExecution() below) or any module it imports.
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||
throw new BootstrapCliError("DATABASE_URL is required.");
|
||||
}
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
|
||||
connectionTimeoutMs: 10_000,
|
||||
operationTimeoutMs: 10_000,
|
||||
});
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
|
||||
console.log(`Platform-owner bootstrap complete for user "${parsedUserId.data}".`);
|
||||
} finally {
|
||||
await close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function reportBootstrapCliError(error: unknown): void {
|
||||
if (error instanceof BootstrapCliError) {
|
||||
console.error(error.message);
|
||||
return;
|
||||
}
|
||||
if (error instanceof PlatformOwnerBootstrapError) {
|
||||
console.error(`[${error.code}] ${error.message}`);
|
||||
return;
|
||||
}
|
||||
console.error("Platform-owner bootstrap failed unexpectedly.");
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
return (
|
||||
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
main().catch((error: unknown) => {
|
||||
reportBootstrapCliError(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pool.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Pool, type PoolConfig } from "pg";
|
||||
|
||||
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
|
||||
|
||||
/** Never carries the offending connection string, host, or underlying driver error. */
|
||||
export class PostgresPoolError extends Error {
|
||||
readonly code: PostgresPoolErrorCode;
|
||||
|
||||
constructor(code: PostgresPoolErrorCode) {
|
||||
super("PostgreSQL pool configuration is invalid.");
|
||||
this.name = "PostgresPoolError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
const maximumPostgresTimeoutMs = 2_147_483_647;
|
||||
|
||||
export interface PostgresPoolOptions {
|
||||
readonly connectionTimeoutMs?: number;
|
||||
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
|
||||
* establishing a connection, not a hung query already running on one. */
|
||||
readonly operationTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PostgresPoolHandle {
|
||||
readonly close: () => Promise<void>;
|
||||
readonly pool: Pool;
|
||||
}
|
||||
|
||||
function validateConnectionString(connectionString: string): string {
|
||||
if (connectionString.trim().length === 0) {
|
||||
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
|
||||
}
|
||||
let protocol: string;
|
||||
try {
|
||||
protocol = new URL(connectionString).protocol;
|
||||
} catch {
|
||||
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
|
||||
}
|
||||
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
/** Mirrors the validation already used by `core/src/identity/database.ts`,
|
||||
* `core/src/authorization/database.ts`, and `core/src/migrations/postgres-database.ts`: an
|
||||
* explicit timeout must be a positive safe integer no larger than PostgreSQL's own maximum, or it
|
||||
* is rejected as invalid configuration rather than silently passed through to `pg`. */
|
||||
function validateTimeout(value: unknown): number | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
typeof value !== "number" ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value <= 0 ||
|
||||
value > maximumPostgresTimeoutMs
|
||||
) {
|
||||
throw new PostgresPoolError("POSTGRES_POOL_CONFIGURATION_INVALID");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ekklesia's one shared, validated `pg.Pool` constructor for a single physical database
|
||||
* connection meant to back more than one typed Drizzle view (for example Core's identity and
|
||||
* authorization schemas sharing one pool in `apps/api`) — one pool, one mandatory error listener,
|
||||
* one `close()`. Building the pool performs no network I/O until a query is issued, and this
|
||||
* function is only ever invoked by explicit caller code — never during module import.
|
||||
*
|
||||
* This does not replace `createIdentityDrizzleDatabase` or `createAuthorizationDrizzleDatabase`:
|
||||
* those remain independent, self-contained factories (each owns a private pool and its own
|
||||
* `close()`) for callers — tests included — that only need one domain. Use this function, plus
|
||||
* `createIdentityDrizzleView`/`createAuthorizationDrizzleView`, specifically when more than one
|
||||
* domain must share one physical connection pool.
|
||||
*/
|
||||
export function createValidatedPostgresPool(
|
||||
connectionString: string,
|
||||
options: PostgresPoolOptions = {},
|
||||
): PostgresPoolHandle {
|
||||
const validatedConnectionString = validateConnectionString(connectionString);
|
||||
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
|
||||
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
|
||||
const poolConfig: PoolConfig = {
|
||||
connectionString: validatedConnectionString,
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
...(validatedOperationTimeoutMs === undefined
|
||||
? {}
|
||||
: {
|
||||
query_timeout: validatedOperationTimeoutMs,
|
||||
statement_timeout: validatedOperationTimeoutMs,
|
||||
}),
|
||||
};
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
// Same rationale as core/src/identity/database.ts: pg's own documentation requires an error
|
||||
// listener on the pool, or a server-initiated termination of an idle client becomes an
|
||||
// unhandled Node.js error and can crash the process.
|
||||
pool.on("error", () => undefined);
|
||||
|
||||
return Object.freeze({
|
||||
close: () => pool.end(),
|
||||
pool,
|
||||
});
|
||||
}
|
||||
@@ -108,3 +108,14 @@ export function createIdentityDrizzleDatabase(
|
||||
db,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A typed Drizzle view over an externally owned pool — for example one shared with the
|
||||
* authorization schema via `createValidatedPostgresPool` (`core/src/database/pool.ts`). Unlike
|
||||
* `createIdentityDrizzleDatabase`, this function never constructs, registers an error listener
|
||||
* on, or closes a pool itself: the caller retains that responsibility exactly once, regardless of
|
||||
* how many typed views it creates over the same pool.
|
||||
*/
|
||||
export function createIdentityDrizzleView(pool: Pool): IdentityDrizzleDatabase {
|
||||
return drizzle(pool, { schema: identityAdapterSchema });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./authorization/index.js";
|
||||
export * from "./database/index.js";
|
||||
export * from "./diagnostics/index.js";
|
||||
export * from "./identity/index.js";
|
||||
export * from "./modules/index.js";
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseEkklesiaUserId } from "@ekklesia/contracts";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../src/authorization/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The bootstrap integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The bootstrap integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_bootstrap_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure remains static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The bootstrap integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function createDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "ekklesia-bootstrap-no-modules-"),
|
||||
);
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function seedUser(client: Client, id: string, email: string): Promise<void> {
|
||||
await safeQuery(
|
||||
client,
|
||||
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values ($1, $2, $3, false, now(), now())`,
|
||||
[id, "Integration Test User", email],
|
||||
);
|
||||
}
|
||||
|
||||
async function ownerRowCount(client: Client): Promise<number> {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
client,
|
||||
`select count(*)::text as count from "ekklesia_core_platform_owner"`,
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("live platform-owner bootstrap", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let db: ReturnType<typeof createAuthorizationDrizzleDatabase>["db"];
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const firstUserId = parseEkklesiaUserId("bootstrap_user_one");
|
||||
const secondUserId = parseEkklesiaUserId("bootstrap_user_two");
|
||||
|
||||
it("bootstraps successfully for an existing user and records exactly one audit entry with the fixed bootstrap actor identity", async () => {
|
||||
database = await createDisposableDatabase("success");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
await seedUser(admin, firstUserId, "[email protected]");
|
||||
await seedUser(admin, secondUserId, "[email protected]");
|
||||
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
db = handle.db;
|
||||
close = handle.close;
|
||||
|
||||
await bootstrapPlatformOwner(db, { userId: firstUserId });
|
||||
|
||||
const owners = await safeQuery<{ user_id: string }>(
|
||||
admin,
|
||||
`select user_id from "ekklesia_core_platform_owner"`,
|
||||
);
|
||||
expect(owners).toEqual([{ user_id: firstUserId }]);
|
||||
|
||||
const audit = await safeQuery<{
|
||||
action: string;
|
||||
actor_id: string | null;
|
||||
actor_type: string;
|
||||
subject_user_id: string;
|
||||
}>(
|
||||
admin,
|
||||
`select action, actor_id, actor_type, subject_user_id from "ekklesia_core_authorization_audit"`,
|
||||
);
|
||||
expect(audit).toEqual([
|
||||
{
|
||||
action: "platform_owner.grant",
|
||||
actor_id: "authorization-bootstrap-cli",
|
||||
actor_type: "system",
|
||||
subject_user_id: firstUserId,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a second bootstrap attempt outright, even for a different user, and leaves no new row or audit entry", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: secondUserId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
|
||||
|
||||
expect(await ownerRowCount(admin)).toBe(1);
|
||||
const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`);
|
||||
expect(audit).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => {
|
||||
await safeQuery(
|
||||
admin,
|
||||
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
|
||||
[firstUserId],
|
||||
);
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: secondUserId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
|
||||
expect(await ownerRowCount(admin)).toBe(1);
|
||||
});
|
||||
|
||||
it("shuts down cleanly", async () => {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live platform-owner bootstrap — nonexistent user", () => {
|
||||
it("rolls back entirely for a user that does not exist, leaving no owner row and no audit entry", async () => {
|
||||
const database = await createDisposableDatabase("missing_user");
|
||||
await applyCoreMigrations(database.url);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
|
||||
try {
|
||||
const missingUserId = parseEkklesiaUserId("bootstrap_user_never_seeded");
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: missingUserId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PlatformOwnerBootstrapError);
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND" });
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
expect(await ownerRowCount(admin)).toBe(0);
|
||||
const audit = await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`);
|
||||
expect(audit).toHaveLength(0);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
} finally {
|
||||
await close();
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live platform-owner bootstrap — concurrency", () => {
|
||||
it("produces exactly one owner when two bootstrap attempts race for two different users", async () => {
|
||||
const database = await createDisposableDatabase("race");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
const raceUserA = parseEkklesiaUserId("bootstrap_race_user_a");
|
||||
const raceUserB = parseEkklesiaUserId("bootstrap_race_user_b");
|
||||
await seedUser(admin, raceUserA, "[email protected]");
|
||||
await seedUser(admin, raceUserB, "[email protected]");
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
bootstrapPlatformOwner(db, { userId: raceUserA }),
|
||||
bootstrapPlatformOwner(db, { userId: raceUserB }),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
||||
const rejected = results.filter((result) => result.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
PlatformOwnerBootstrapError,
|
||||
);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toMatchObject({
|
||||
code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
|
||||
});
|
||||
|
||||
const owners = await safeQuery<{ user_id: string }>(
|
||||
admin,
|
||||
`select user_id from "ekklesia_core_platform_owner"`,
|
||||
);
|
||||
expect(owners).toHaveLength(1);
|
||||
expect([raceUserA, raceUserB]).toContain(owners[0]?.user_id);
|
||||
|
||||
const audit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
} finally {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
|
||||
import {
|
||||
bootstrapPlatformOwner,
|
||||
PlatformOwnerBootstrapError,
|
||||
} from "../src/authorization/bootstrap.js";
|
||||
|
||||
describe("Core authorization bootstrap — import and construction safety", () => {
|
||||
it("does not construct a pool or open a connection merely because the module is imported", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("performs no database work merely by being imported: bootstrapPlatformOwner is only a function reference until called", () => {
|
||||
expect(typeof bootstrapPlatformOwner).toBe("function");
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlatformOwnerBootstrapError", () => {
|
||||
it.each([
|
||||
"PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
|
||||
"PLATFORM_OWNER_BOOTSTRAP_FAILED",
|
||||
"PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND",
|
||||
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
|
||||
const error = new PlatformOwnerBootstrapError(code);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.name).toBe("PlatformOwnerBootstrapError");
|
||||
expect(Object.isFrozen(error)).toBe(true);
|
||||
expect(error.message.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
|
||||
const error = new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_FAILED");
|
||||
const publicText = `${String(error)}\n${error.stack ?? ""}`;
|
||||
expect(publicText).toBe(
|
||||
`PlatformOwnerBootstrapError: ${error.message}\nPlatformOwnerBootstrapError: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapPlatformOwner — construction without a real database", () => {
|
||||
it("does not construct a pool merely because a database handle was created; only calling bootstrapPlatformOwner would query", () => {
|
||||
poolConstructor.mockClear();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
|
||||
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(db).toBeDefined();
|
||||
// bootstrapPlatformOwner is intentionally not invoked here: exercising the real transactional
|
||||
// and advisory-lock behavior requires a real PostgreSQL server and is covered by
|
||||
// core/tests/authorization-bootstrap.integration.ts.
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import {
|
||||
AuthorizationDatabaseError,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
} from "../src/authorization/database.js";
|
||||
|
||||
describe("Core authorization Drizzle database composition", () => {
|
||||
it("does not construct a pool merely because the module is imported", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
"not-a-database-url",
|
||||
"https://admin:[email protected]/database",
|
||||
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createAuthorizationDrizzleDatabase(connectionString);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
if (connectionString) {
|
||||
expect(publicText).not.toContain(connectionString);
|
||||
}
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explicitly constructs the pool only when called, with the authorization schema attached", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
|
||||
const handle = createAuthorizationDrizzleDatabase(
|
||||
"postgresql://operator:[email protected]/ekklesia",
|
||||
);
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(handle.db).toBeDefined();
|
||||
expect(typeof handle.close).toBe("function");
|
||||
expect(Object.isFrozen(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the connection timeout through to the pool configuration", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
|
||||
connectionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the operation timeout through to the pool configuration as both query_timeout and statement_timeout", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
|
||||
operationTimeoutMs: 7_500,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not set query_timeout, statement_timeout, or connectionTimeoutMillis when no timeout is configured", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
|
||||
const [poolConfig] = poolConstructor.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(poolConfig).not.toHaveProperty("query_timeout");
|
||||
expect(poolConfig).not.toHaveProperty("statement_timeout");
|
||||
expect(poolConfig).not.toHaveProperty("connectionTimeoutMillis");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["operationTimeoutMs", 0],
|
||||
["operationTimeoutMs", -1],
|
||||
["operationTimeoutMs", Number.NaN],
|
||||
["operationTimeoutMs", 1.5],
|
||||
["operationTimeoutMs", 2_147_483_648],
|
||||
["connectionTimeoutMs", 0],
|
||||
["connectionTimeoutMs", -1],
|
||||
["connectionTimeoutMs", Number.NaN],
|
||||
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
|
||||
poolConstructor.mockClear();
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia", {
|
||||
[optionName]: value,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(AuthorizationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_DATABASE_CONFIGURATION_INVALID" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain(String(value));
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
|
||||
const on = vi.fn();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on };
|
||||
});
|
||||
|
||||
createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
|
||||
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
|
||||
| ((error: unknown) => void)
|
||||
| undefined;
|
||||
expect(() =>
|
||||
handler?.(new Error("terminating connection due to administrator command")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuthorizationDrizzleView", () => {
|
||||
it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => {
|
||||
poolConstructor.mockClear();
|
||||
const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() };
|
||||
|
||||
// A minimal fake pg.Pool double is sufficient — this test proves
|
||||
// createAuthorizationDrizzleView never touches the pool beyond wrapping it.
|
||||
const db = createAuthorizationDrizzleView(fakePool as unknown as Pool);
|
||||
|
||||
expect(db).toBeDefined();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(fakePool.on).not.toHaveBeenCalled();
|
||||
expect(fakePool.end).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,544 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type ActiveGrantQuery,
|
||||
AuthorizationEvaluationError,
|
||||
type AuthorizationGrantPort,
|
||||
type AuthorizationPlatformOwnerPort,
|
||||
type AuthorizeRequest,
|
||||
createAuthorizationEvaluator,
|
||||
} from "../src/authorization/index.js";
|
||||
|
||||
interface FakeGrant {
|
||||
readonly userId: string;
|
||||
readonly permission: string;
|
||||
readonly scope?: string;
|
||||
readonly expiresAt?: Date;
|
||||
readonly revokedAt?: Date;
|
||||
}
|
||||
|
||||
/** An in-memory double implementing the exact scope/expiry/revocation matching semantics the real
|
||||
* Postgres store implements in SQL, so the evaluator's own decision logic can be exercised in
|
||||
* isolation. The live integration suite separately proves the SQL performs the same matching. */
|
||||
class FakeGrantStore implements AuthorizationGrantPort {
|
||||
readonly grants: FakeGrant[] = [];
|
||||
readonly calls: ActiveGrantQuery[] = [];
|
||||
|
||||
async hasActiveGrant(query: ActiveGrantQuery): Promise<boolean> {
|
||||
this.calls.push(query);
|
||||
return this.grants.some((grant) => {
|
||||
if (grant.userId !== query.userId || grant.permission !== query.permission) {
|
||||
return false;
|
||||
}
|
||||
if (grant.revokedAt !== undefined) {
|
||||
return false;
|
||||
}
|
||||
if (grant.expiresAt !== undefined && grant.expiresAt <= query.now) {
|
||||
return false;
|
||||
}
|
||||
if (grant.scope === undefined) {
|
||||
return true; // an unscoped grant satisfies any request for the same permission
|
||||
}
|
||||
return grant.scope === query.scope; // a scoped grant never satisfies an unscoped request
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOwnerStore implements AuthorizationPlatformOwnerPort {
|
||||
private readonly owners = new Set<string>();
|
||||
readonly calls: string[] = [];
|
||||
|
||||
makeOwner(userId: string): void {
|
||||
this.owners.add(userId);
|
||||
}
|
||||
|
||||
async isPlatformOwner(userId: string): Promise<boolean> {
|
||||
this.calls.push(userId);
|
||||
return this.owners.has(userId);
|
||||
}
|
||||
}
|
||||
|
||||
const userId = parseEkklesiaUserId("user_1");
|
||||
const otherUserId = parseEkklesiaUserId("user_2");
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
const scope = parseAuthorizationScope("calendar-event:123");
|
||||
const otherScope = parseAuthorizationScope("calendar-event:456");
|
||||
|
||||
describe("Core authorization evaluator", () => {
|
||||
let grants: FakeGrantStore;
|
||||
let owners: FakeOwnerStore;
|
||||
|
||||
beforeEach(() => {
|
||||
grants = new FakeGrantStore();
|
||||
owners = new FakeOwnerStore();
|
||||
});
|
||||
|
||||
function evaluator(now?: () => Date) {
|
||||
return createAuthorizationEvaluator({
|
||||
grants,
|
||||
platformOwners: owners,
|
||||
...(now ? { now } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("public baseline", () => {
|
||||
it("allows an anonymous principal", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "public",
|
||||
permission,
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("allows an authenticated principal", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "public",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("never queries grants or platform-owner state for a public check", async () => {
|
||||
await evaluator().authorize({
|
||||
baseline: "public",
|
||||
permission,
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies an anonymous principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "public",
|
||||
permission: "not a valid permission id",
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies an authenticated principal when the permission id is malformed", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "public",
|
||||
permission: "not a valid permission id",
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticated baseline", () => {
|
||||
it("allows an authenticated principal", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "authenticated",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("rejects an anonymous principal", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "authenticated",
|
||||
permission,
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("never queries grants or platform-owner state for an authenticated check", async () => {
|
||||
await evaluator().authorize({
|
||||
baseline: "authenticated",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies an authenticated principal when the permission id is malformed, without throwing or querying grants or ownership", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "authenticated",
|
||||
permission: "not a valid permission id",
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still denies an anonymous principal when the permission id is malformed too", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "authenticated",
|
||||
permission: "not a valid permission id",
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restricted baseline", () => {
|
||||
it("denies an anonymous principal without ever consulting grants or ownership", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
expect(owners.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies an authenticated principal with no grant and no ownership", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("allows an authenticated principal holding an active explicit grant", async () => {
|
||||
grants.grants.push({ permission, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("allows an authenticated principal holding an active relationship-derived grant", async () => {
|
||||
// The evaluator only distinguishes "active grant or not" through the port; source kind is a
|
||||
// store/audit concern, not an evaluation input — proven here by a grant the fake store
|
||||
// exposes with no source-kind field at all still being honored.
|
||||
grants.grants.push({ permission, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("matches permissions exactly and does not treat a namespace prefix as inherited authority", async () => {
|
||||
grants.grants.push({ permission: parsePermissionId("calendar.events.read.detail"), userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("denies a grant that belongs to a different user", async () => {
|
||||
grants.grants.push({ permission, userId: otherUserId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("lets an unscoped grant satisfy an unscoped request", async () => {
|
||||
grants.grants.push({ permission, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("lets an unscoped grant satisfy a scoped request for the same permission", async () => {
|
||||
grants.grants.push({ permission, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
scope,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("lets a scoped grant satisfy a request for the exact matching scope", async () => {
|
||||
grants.grants.push({ permission, scope, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
scope,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("denies a scoped grant against a different scope", async () => {
|
||||
grants.grants.push({ permission, scope, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
scope: otherScope,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("never lets a scoped grant satisfy an unscoped request", async () => {
|
||||
grants.grants.push({ permission, scope, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("denies an expired grant", async () => {
|
||||
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
|
||||
grants.grants.push({
|
||||
expiresAt: new Date("2025-12-31T00:00:00.000Z"),
|
||||
permission,
|
||||
userId,
|
||||
});
|
||||
const decision = await evaluator(() => fixedNow).authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("allows a grant that has not yet expired, evaluated deterministically against an injected clock", async () => {
|
||||
const fixedNow = new Date("2026-01-01T00:00:00.000Z");
|
||||
grants.grants.push({
|
||||
expiresAt: new Date("2026-01-02T00:00:00.000Z"),
|
||||
permission,
|
||||
userId,
|
||||
});
|
||||
const decision = await evaluator(() => fixedNow).authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("denies a revoked grant even though it has not expired", async () => {
|
||||
grants.grants.push({
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
permission,
|
||||
revokedAt: new Date(),
|
||||
userId,
|
||||
});
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("denies an unknown or malformed permission without throwing", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission: "not a valid permission id",
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
expect(grants.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies a wildcard-shaped permission without throwing", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission: "media.*",
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("denies a non-string permission (number, object, null, undefined) without throwing", async () => {
|
||||
for (const malformed of [42, {}, null, undefined, ["calendar.events.read"]]) {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission: malformed,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("denies a malformed scope without throwing", async () => {
|
||||
grants.grants.push({ permission, userId });
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
scope: "",
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
|
||||
it("allows a platform owner with no matching grant at all", async () => {
|
||||
owners.makeOwner(userId);
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("allows a platform owner for a scoped restricted permission with no scoped grant", async () => {
|
||||
owners.makeOwner(userId);
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
scope,
|
||||
});
|
||||
expect(decision).toEqual({ allowed: true });
|
||||
});
|
||||
|
||||
it("does not grant ownership authority to a different user", async () => {
|
||||
owners.makeOwner(otherUserId);
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an unknown or malformed baseline", () => {
|
||||
it("denies rather than defaulting to any known baseline", async () => {
|
||||
for (const malformed of ["superuser", "", 42, null, undefined, "Public", "PUBLIC"]) {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: malformed,
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("a client cannot inject owner or grant state", () => {
|
||||
it("ignores extra caller-supplied fields such as isOwner/hasGrant and re-derives authority from the injected ports", async () => {
|
||||
// Simulates a caller that smuggles extra fields the AuthorizeRequest type does not
|
||||
// declare — going through `unknown` rather than `any` proves the object still satisfies
|
||||
// AuthorizeRequest structurally; the extra fields are simply never read.
|
||||
const hostileRequest = {
|
||||
baseline: "restricted",
|
||||
hasGrant: true,
|
||||
isOwner: true,
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
} as unknown as AuthorizeRequest;
|
||||
|
||||
const decision = await evaluator().authorize(hostileRequest);
|
||||
|
||||
expect(decision).toEqual({ allowed: false });
|
||||
// The evaluator still had to actually ask the injected ports — it did not shortcut on the
|
||||
// hostile flags.
|
||||
expect(grants.calls).toHaveLength(1);
|
||||
expect(owners.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decision contract", () => {
|
||||
it("returns a frozen, minimal decision with no extra fields", async () => {
|
||||
const decision = await evaluator().authorize({
|
||||
baseline: "public",
|
||||
permission,
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
});
|
||||
expect(Object.isFrozen(decision)).toBe(true);
|
||||
expect(Object.keys(decision)).toEqual(["allowed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitized failures", () => {
|
||||
it("propagates a genuine grant-lookup infrastructure failure rather than silently denying", async () => {
|
||||
const failingGrants: AuthorizationGrantPort = {
|
||||
hasActiveGrant: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
|
||||
};
|
||||
const failingEvaluator = createAuthorizationEvaluator({
|
||||
grants: failingGrants,
|
||||
platformOwners: owners,
|
||||
});
|
||||
|
||||
await expect(
|
||||
failingEvaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "AUTHORIZATION_EVALUATION_FAILED",
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates a genuine platform-owner lookup infrastructure failure rather than silently denying", async () => {
|
||||
const failingOwners: AuthorizationPlatformOwnerPort = {
|
||||
isPlatformOwner: vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")),
|
||||
};
|
||||
const failingEvaluator = createAuthorizationEvaluator({
|
||||
grants,
|
||||
platformOwners: failingOwners,
|
||||
});
|
||||
|
||||
await expect(
|
||||
failingEvaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(AuthorizationEvaluationError);
|
||||
});
|
||||
|
||||
it("never leaks a raw infrastructure error message through the thrown error", async () => {
|
||||
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
|
||||
const failingGrants: AuthorizationGrantPort = {
|
||||
hasActiveGrant: vi.fn().mockRejectedValue(new Error(hostileMessage)),
|
||||
};
|
||||
const failingEvaluator = createAuthorizationEvaluator({
|
||||
grants: failingGrants,
|
||||
platformOwners: owners,
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await failingEvaluator.authorize({
|
||||
baseline: "restricted",
|
||||
permission,
|
||||
principal: createAuthenticatedPrincipal(userId),
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("hunter2");
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { parsePermissionId } from "@ekklesia/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
createPermissionRegistry,
|
||||
PermissionRegistryError,
|
||||
} from "../src/authorization/index.js";
|
||||
|
||||
describe("createPermissionRegistry", () => {
|
||||
it("builds an immutable registry from a fixed declaration list", () => {
|
||||
const id = parsePermissionId("test.example.read");
|
||||
const registry = createPermissionRegistry([
|
||||
{ baseline: "public", description: "An example permission.", id },
|
||||
]);
|
||||
|
||||
expect(registry.has(id)).toBe(true);
|
||||
expect(registry.get(id)).toEqual({
|
||||
baseline: "public",
|
||||
description: "An example permission.",
|
||||
id,
|
||||
});
|
||||
expect(Object.isFrozen(registry)).toBe(true);
|
||||
expect(Object.isFrozen(registry.declarations)).toBe(true);
|
||||
expect(Object.isFrozen(registry.get(id))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a duplicate permission id at composition time, not per request", () => {
|
||||
const id = parsePermissionId("test.example.read");
|
||||
expect(() =>
|
||||
createPermissionRegistry([
|
||||
{ baseline: "public", description: "First.", id },
|
||||
{ baseline: "restricted", description: "Second.", id },
|
||||
]),
|
||||
).toThrow(PermissionRegistryError);
|
||||
});
|
||||
|
||||
it("carries the offending permission id on the duplicate-declaration error (a composition-time, developer-facing detail, not a runtime response)", () => {
|
||||
const id = parsePermissionId("test.example.read");
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPermissionRegistry([
|
||||
{ baseline: "public", description: "First.", id },
|
||||
{ baseline: "restricted", description: "Second.", id },
|
||||
]);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(PermissionRegistryError);
|
||||
expect(captured).toMatchObject({
|
||||
code: "PERMISSION_REGISTRY_DUPLICATE_DECLARATION",
|
||||
permissionId: id,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an unregistered permission the same as a malformed one: get/has both fail closed", () => {
|
||||
const registered = parsePermissionId("test.example.read");
|
||||
const unregistered = parsePermissionId("test.example.unregistered");
|
||||
const registry = createPermissionRegistry([
|
||||
{ baseline: "public", description: "Registered.", id: registered },
|
||||
]);
|
||||
|
||||
expect(registry.has(unregistered)).toBe(false);
|
||||
expect(registry.get(unregistered)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails closed for a malformed permission id passed to get/has, without throwing", () => {
|
||||
const registry = createPermissionRegistry([]);
|
||||
|
||||
expect(registry.has("not a valid permission")).toBe(false);
|
||||
expect(registry.get(null)).toBeUndefined();
|
||||
expect(registry.get(undefined)).toBeUndefined();
|
||||
expect(registry.get(42)).toBeUndefined();
|
||||
expect(registry.get("media.*")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds an empty registry from an empty list without error", () => {
|
||||
const registry = createPermissionRegistry([]);
|
||||
expect(registry.declarations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
it("registers exactly core.authorization.grants.manage as restricted", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_AUTHORIZATION_GRANTS_MANAGE);
|
||||
expect(declaration).toBeDefined();
|
||||
expect(declaration?.baseline).toBe("restricted");
|
||||
expect(declaration?.id).toBe("core.authorization.grants.manage");
|
||||
});
|
||||
|
||||
it("contains no other permission in this slice", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
type AuthorizationScope,
|
||||
createUserActor,
|
||||
type GrantId,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parsePermissionId,
|
||||
SYSTEM_ACTOR,
|
||||
} from "@ekklesia/contracts";
|
||||
import { Client, type QueryResultRow } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AuthorizationStoreError,
|
||||
createAuthorizationDrizzleDatabase,
|
||||
createAuthorizationStore,
|
||||
} from "../src/authorization/index.js";
|
||||
import {
|
||||
applyModuleMigrations,
|
||||
createPostgresMigrationDatabase,
|
||||
discoverAllMigrations,
|
||||
} from "../src/migrations/index.js";
|
||||
|
||||
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||
const coreMigrationsDirectory = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"drizzle",
|
||||
);
|
||||
const trackedDatabases = new Set<string>();
|
||||
let databaseCounter = 0;
|
||||
|
||||
class IntegrationHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "IntegrationHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function harnessError(message: string): IntegrationHarnessError {
|
||||
return new IntegrationHarnessError(message);
|
||||
}
|
||||
|
||||
function baseDatabaseUrl(): string {
|
||||
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw harnessError("The authorization integration database is not configured.");
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||
throw new TypeError("Unsupported protocol.");
|
||||
}
|
||||
} catch {
|
||||
throw harnessError("The authorization integration database configuration is invalid.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function quoteDatabaseName(databaseName: string): string {
|
||||
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||
throw harnessError("The generated integration database name is invalid.");
|
||||
}
|
||||
return `"${databaseName}"`;
|
||||
}
|
||||
|
||||
function makeDatabaseName(label: string): string {
|
||||
const safeLabel = label
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||
.replaceAll(/^_+|_+$/gu, "")
|
||||
.slice(0, 14);
|
||||
databaseCounter += 1;
|
||||
return `ekk_authz_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
function databaseUrl(databaseName: string): string {
|
||||
const parsed = new URL(baseDatabaseUrl());
|
||||
parsed.pathname = `/${databaseName}`;
|
||||
parsed.searchParams.delete("application_name");
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||
const client = new Client({ connectionString });
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The public harness failure remains static when connection cleanup also fails.
|
||||
}
|
||||
throw harnessError("The authorization integration database connection failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeQuery<Row extends QueryResultRow>(
|
||||
client: Client,
|
||||
text: string,
|
||||
values: unknown[] = [],
|
||||
): Promise<readonly Row[]> {
|
||||
try {
|
||||
const result = await client.query<Row>(text, values);
|
||||
return result.rows;
|
||||
} catch {
|
||||
throw harnessError("An integration database administration operation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeEnd(client: Client): Promise<void> {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
throw harnessError("An integration database connection could not be closed.");
|
||||
}
|
||||
}
|
||||
|
||||
async function createDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function dropDatabase(databaseName: string): Promise<void> {
|
||||
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||
try {
|
||||
await safeQuery(
|
||||
admin,
|
||||
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||
[databaseName],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||
);
|
||||
trackedDatabases.delete(databaseName);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||
const name = makeDatabaseName(label);
|
||||
await createDatabase(name);
|
||||
trackedDatabases.add(name);
|
||||
return { name, url: databaseUrl(name) };
|
||||
}
|
||||
|
||||
async function applyCoreMigrations(url: string): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-authz-no-modules-"));
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||
"core:0000_identity_core_tables",
|
||||
"core:0001_authorization_core_tables",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function seedUser(client: Client, id: string, email: string): Promise<void> {
|
||||
await safeQuery(
|
||||
client,
|
||||
`insert into "ekklesia_core_user" (id, name, email, email_verified, created_at, updated_at)
|
||||
values ($1, $2, $3, false, now(), now())`,
|
||||
[id, "Integration Test User", email],
|
||||
);
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const databaseName of [...trackedDatabases]) {
|
||||
await dropDatabase(databaseName).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("live Core authorization store", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: ReturnType<typeof createAuthorizationStore>;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const userOneId = parseEkklesiaUserId("authz_user_one");
|
||||
const userTwoId = parseEkklesiaUserId("authz_user_two");
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123");
|
||||
const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject
|
||||
|
||||
it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => {
|
||||
database = await createDisposableDatabase("fresh");
|
||||
await applyCoreMigrations(database.url);
|
||||
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
const tables = await safeQuery<{ table_name: string }>(
|
||||
admin,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name like 'ekklesia_core_%'
|
||||
order by table_name`,
|
||||
);
|
||||
expect(tables.map((row) => row.table_name)).toEqual([
|
||||
"ekklesia_core_account",
|
||||
"ekklesia_core_authorization_audit",
|
||||
"ekklesia_core_grant",
|
||||
"ekklesia_core_migrations",
|
||||
"ekklesia_core_platform_owner",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_user",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
await seedUser(admin, userOneId, "[email protected]");
|
||||
await seedUser(admin, userTwoId, "[email protected]");
|
||||
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
close = handle.close;
|
||||
store = createAuthorizationStore(handle.db);
|
||||
});
|
||||
|
||||
it("creates a grant and finds it as active for the matching user, permission, and scope", async () => {
|
||||
const grant = await store.createGrant({
|
||||
actor,
|
||||
permission,
|
||||
scope,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
|
||||
expect(grant.userId).toBe(userOneId);
|
||||
expect(grant.permission).toBe(permission);
|
||||
expect(grant.scope).toBe(scope);
|
||||
expect(grant.sourceKind).toBe("explicit");
|
||||
expect(grant.revokedAt).toBeUndefined();
|
||||
|
||||
const active = await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission,
|
||||
scope,
|
||||
userId: userOneId,
|
||||
});
|
||||
expect(active).toBe(true);
|
||||
});
|
||||
|
||||
it("does not find the grant for a different user, a different permission, or a different scope", async () => {
|
||||
expect(
|
||||
await store.hasActiveGrant({ now: new Date(), permission, scope, userId: userTwoId }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: parsePermissionId("calendar.events.write"),
|
||||
scope,
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission,
|
||||
scope: parseAuthorizationScope("calendar-event:456"),
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("never lets the scoped grant satisfy an unscoped request", async () => {
|
||||
const active = await store.hasActiveGrant({ now: new Date(), permission, userId: userOneId });
|
||||
expect(active).toBe(false);
|
||||
});
|
||||
|
||||
it("lets an unscoped grant satisfy both unscoped and scoped requests for the same permission", async () => {
|
||||
const unscopedPermission = parsePermissionId("calendar.events.list");
|
||||
await store.createGrant({
|
||||
actor,
|
||||
permission: unscopedPermission,
|
||||
sourceKind: "relationship",
|
||||
sourceRef: "roster:volunteer:42",
|
||||
userId: userOneId,
|
||||
});
|
||||
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: unscopedPermission,
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: unscopedPermission,
|
||||
scope: parseAuthorizationScope("anything-at-all"),
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("records exactly one atomic audit entry for the grant it just created", async () => {
|
||||
const rows = await safeQuery<{
|
||||
action: string;
|
||||
actor_id: string | null;
|
||||
actor_type: string;
|
||||
permission_id: string | null;
|
||||
scope: string | null;
|
||||
subject_user_id: string;
|
||||
}>(
|
||||
admin,
|
||||
`select action, actor_id, actor_type, permission_id, scope, subject_user_id
|
||||
from "ekklesia_core_authorization_audit"
|
||||
where subject_user_id = $1 and permission_id = $2`,
|
||||
[userOneId, permission],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
action: "grant.create",
|
||||
actor_id: userTwoId,
|
||||
actor_type: "user",
|
||||
permission_id: permission,
|
||||
scope,
|
||||
subject_user_id: userOneId,
|
||||
});
|
||||
});
|
||||
|
||||
it("denies a grant once it expires, evaluated against a real elapsed instant", async () => {
|
||||
const expiringPermission = parsePermissionId("calendar.events.export");
|
||||
const past = new Date(Date.now() - 1_000);
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt: past,
|
||||
permission: expiringPermission,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
|
||||
const active = await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: expiringPermission,
|
||||
userId: userOneId,
|
||||
});
|
||||
expect(active).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a grant that has not yet expired", async () => {
|
||||
const notYetExpiredPermission = parsePermissionId("calendar.events.import");
|
||||
const future = new Date(Date.now() + 86_400_000);
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt: future,
|
||||
permission: notYetExpiredPermission,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
|
||||
const active = await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: notYetExpiredPermission,
|
||||
userId: userOneId,
|
||||
});
|
||||
expect(active).toBe(true);
|
||||
});
|
||||
|
||||
const revocablePermission = parsePermissionId("calendar.events.delete");
|
||||
let revocableGrantId: GrantId;
|
||||
|
||||
it("revokes an active grant, records the revocation audit entry, and stops it authorizing", async () => {
|
||||
const grant = await store.createGrant({
|
||||
actor,
|
||||
permission: revocablePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
revocableGrantId = grant.id;
|
||||
|
||||
await store.revokeGrant({ actor, grantId: grant.id });
|
||||
|
||||
const active = await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: revocablePermission,
|
||||
userId: userOneId,
|
||||
});
|
||||
expect(active).toBe(false);
|
||||
|
||||
const audit = await safeQuery<{ action: string }>(
|
||||
admin,
|
||||
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
|
||||
[revocableGrantId],
|
||||
);
|
||||
expect(audit.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
|
||||
});
|
||||
|
||||
it("refuses to revoke an already-revoked grant a second time", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.revokeGrant({ actor, grantId: revocableGrantId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rolls back the whole grant-plus-audit transaction when the audit write violates a real constraint", async () => {
|
||||
const doomedPermission = parsePermissionId("calendar.events.archive");
|
||||
// The audit table's scope CHECK constraint rejects a non-null empty string; the grant table
|
||||
// has no such constraint. Bypassing the application-layer AuthorizationScope validation (which
|
||||
// never produces an empty string) with a raw cast lets a real Postgres constraint violation
|
||||
// fire on the audit insert specifically, after the grant insert within the same transaction
|
||||
// would otherwise have succeeded.
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({
|
||||
actor,
|
||||
permission: doomedPermission,
|
||||
scope: "" as AuthorizationScope,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
|
||||
|
||||
const orphanedGrants = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_grant" where permission_id = $1`,
|
||||
[doomedPermission],
|
||||
);
|
||||
expect(orphanedGrants).toHaveLength(0);
|
||||
const orphanedAudit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
||||
[doomedPermission],
|
||||
);
|
||||
expect(orphanedAudit).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is not a platform owner before ever being granted ownership", async () => {
|
||||
expect(await store.isPlatformOwner(userOneId)).toBe(false);
|
||||
});
|
||||
|
||||
it("grants, records the audit entry for, and looks up platform-owner authority", async () => {
|
||||
await store.grantPlatformOwner(userOneId, actor);
|
||||
|
||||
expect(await store.isPlatformOwner(userOneId)).toBe(true);
|
||||
expect(await store.isPlatformOwner(userTwoId)).toBe(false);
|
||||
|
||||
const audit = await safeQuery<{ action: string; subject_user_id: string }>(
|
||||
admin,
|
||||
`select action, subject_user_id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
|
||||
);
|
||||
expect(audit).toEqual([{ action: "platform_owner.grant", subject_user_id: userOneId }]);
|
||||
});
|
||||
|
||||
it("revokes platform-owner authority, records the audit entry, and stops future lookups from succeeding", async () => {
|
||||
await store.revokePlatformOwner(userOneId, actor);
|
||||
|
||||
expect(await store.isPlatformOwner(userOneId)).toBe(false);
|
||||
|
||||
const audit = await safeQuery<{ action: string }>(
|
||||
admin,
|
||||
`select action from "ekklesia_core_authorization_audit" where action = 'platform_owner.revoke'`,
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("refuses to revoke platform-owner authority a user never held (or already lost)", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.revokePlatformOwner(userTwoId, actor);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("re-grants platform-owner authority after a revocation (upsert), reactivating rather than duplicating the row", async () => {
|
||||
await store.grantPlatformOwner(userOneId, SYSTEM_ACTOR);
|
||||
expect(await store.isPlatformOwner(userOneId)).toBe(true);
|
||||
|
||||
const rows = await safeQuery(
|
||||
admin,
|
||||
`select user_id from "ekklesia_core_platform_owner" where user_id = $1`,
|
||||
[userOneId],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shuts down cleanly", async () => {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live Core authorization store — no automatic migration", () => {
|
||||
it("creates no authorization tables merely by constructing the database, the store, or an evaluator against an unmigrated database", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
try {
|
||||
const { db, close } = createAuthorizationDrizzleDatabase(database.url);
|
||||
const store = createAuthorizationStore(db);
|
||||
expect(store).toBeDefined();
|
||||
await close();
|
||||
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
const tables = await safeQuery(
|
||||
admin,
|
||||
`select table_name from information_schema.tables where table_schema = 'public'`,
|
||||
);
|
||||
expect(tables).toHaveLength(0);
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
}
|
||||
} finally {
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
|
||||
import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js";
|
||||
|
||||
describe("Core authorization store — import and construction safety", () => {
|
||||
it("does not construct a pool or open a connection merely because the module is imported", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not perform any query merely because the store is constructed", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn(), query: vi.fn() };
|
||||
});
|
||||
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||
|
||||
const store = createAuthorizationStore(db);
|
||||
|
||||
expect(store).toBeDefined();
|
||||
expect(typeof store.createGrant).toBe("function");
|
||||
expect(typeof store.revokeGrant).toBe("function");
|
||||
expect(typeof store.hasActiveGrant).toBe("function");
|
||||
expect(typeof store.grantPlatformOwner).toBe("function");
|
||||
expect(typeof store.revokePlatformOwner).toBe("function");
|
||||
expect(typeof store.isPlatformOwner).toBe("function");
|
||||
expect(Object.isFrozen(store)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationStoreError", () => {
|
||||
it.each([
|
||||
"AUTHORIZATION_GRANT_CREATE_FAILED",
|
||||
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
|
||||
"AUTHORIZATION_GRANT_NOT_FOUND",
|
||||
"AUTHORIZATION_GRANT_REVOKE_FAILED",
|
||||
"AUTHORIZATION_PLATFORM_OWNER_LOOKUP_FAILED",
|
||||
"AUTHORIZATION_PLATFORM_OWNER_MUTATION_FAILED",
|
||||
"AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND",
|
||||
] as const)("exposes a stable code and a fixed, generic message for %s", (code) => {
|
||||
const error = new AuthorizationStoreError(code);
|
||||
expect(error.code).toBe(code);
|
||||
expect(error.name).toBe("AuthorizationStoreError");
|
||||
expect(Object.isFrozen(error)).toBe(true);
|
||||
expect(error.message.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never carries a cause, raw driver error, SQL, or stack detail beyond the fixed message", () => {
|
||||
const error = new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED");
|
||||
const publicText = `${String(error)}\n${error.stack ?? ""}`;
|
||||
expect(publicText).toBe(
|
||||
`AuthorizationStoreError: ${error.message}\nAuthorizationStoreError: ${error.message}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
describe("bootstrap-platform-owner CLI import safety", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
it("does not read DATABASE_URL, connect to PostgreSQL, or run bootstrap merely by importing the module", async () => {
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
// Under Vitest, process.argv[1] is never this file's own path, so isDirectExecution() is
|
||||
// false the same way it naturally is for apps/api/src/server.ts under its own test suite —
|
||||
// no argv manipulation is needed to prove the guard holds.
|
||||
await expect(import("../src/cli/bootstrap-platform-owner.js")).resolves.toBeDefined();
|
||||
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
|
||||
process.env = originalEnv;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createValidatedPostgresPool, PostgresPoolError } from "../src/database/pool.js";
|
||||
|
||||
describe("createValidatedPostgresPool", () => {
|
||||
it("does not construct a pool merely because the module is imported", () => {
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"",
|
||||
" ",
|
||||
"not-a-database-url",
|
||||
"https://admin:[email protected]/database",
|
||||
])("rejects an invalid connection string without exposing it (%s)", (connectionString) => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createValidatedPostgresPool(connectionString);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresPoolError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" });
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
if (connectionString) {
|
||||
expect(publicText).not.toContain(connectionString);
|
||||
}
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explicitly constructs the pool only when called", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
|
||||
const handle = createValidatedPostgresPool(
|
||||
"postgresql://operator:[email protected]/ekklesia",
|
||||
);
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(handle.pool).toBeDefined();
|
||||
expect(typeof handle.close).toBe("function");
|
||||
expect(Object.isFrozen(handle)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the connection timeout through to the pool configuration", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
|
||||
connectionTimeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the operation timeout through as both query_timeout and statement_timeout", () => {
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on: vi.fn() };
|
||||
});
|
||||
poolConstructor.mockClear();
|
||||
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
|
||||
operationTimeoutMs: 7_500,
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ query_timeout: 7_500, statement_timeout: 7_500 }),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["operationTimeoutMs", 0],
|
||||
["operationTimeoutMs", -1],
|
||||
["operationTimeoutMs", Number.NaN],
|
||||
["connectionTimeoutMs", 0],
|
||||
["connectionTimeoutMs", -1],
|
||||
] as const)("rejects an invalid %s (%s) without constructing a pool and without leaking the value", (optionName, value) => {
|
||||
poolConstructor.mockClear();
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia", {
|
||||
[optionName]: value,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresPoolError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_POOL_CONFIGURATION_INVALID" });
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
|
||||
const on = vi.fn();
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: async () => undefined, on };
|
||||
});
|
||||
|
||||
createValidatedPostgresPool("postgresql://example.test/ekklesia");
|
||||
|
||||
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
|
||||
| ((error: unknown) => void)
|
||||
| undefined;
|
||||
expect(() =>
|
||||
handler?.(new Error("terminating connection due to administrator command")),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { Pool } from "pg";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||
|
||||
import { createIdentityDrizzleDatabase, IdentityDatabaseError } from "../src/identity/database.js";
|
||||
import {
|
||||
createIdentityDrizzleDatabase,
|
||||
createIdentityDrizzleView,
|
||||
IdentityDatabaseError,
|
||||
} from "../src/identity/database.js";
|
||||
|
||||
describe("Core identity Drizzle database composition", () => {
|
||||
it("does not construct a pool merely because the module is imported", () => {
|
||||
@@ -141,3 +146,19 @@ describe("Core identity Drizzle database composition", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createIdentityDrizzleView", () => {
|
||||
it("wraps an externally owned pool without constructing, registering an error listener on, or closing one itself", () => {
|
||||
poolConstructor.mockClear();
|
||||
const fakePool = { end: vi.fn(), on: vi.fn(), query: vi.fn() };
|
||||
|
||||
// A minimal fake pg.Pool double is sufficient — this test proves createIdentityDrizzleView
|
||||
// never touches the pool beyond wrapping it.
|
||||
const db = createIdentityDrizzleView(fakePool as unknown as Pool);
|
||||
|
||||
expect(db).toBeDefined();
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(fakePool.on).not.toHaveBeenCalled();
|
||||
expect(fakePool.end).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# 0013: Core authorization, permission registry, and platform-owner bootstrap
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-23
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0010 and ADR 0011 established Core-owned identity and its HTTP transport but explicitly left
|
||||
authorization out of scope: every account received identical baseline authenticated status, and
|
||||
signup order never granted elevation. `docs/architecture/platform-architecture.md` names
|
||||
permissions as a Core concern (ADR 0001), not a feature module or a surface concern, alongside
|
||||
identity, database connectivity, and module lifecycle.
|
||||
|
||||
Phase 4C adds that missing piece in two slices on this branch. The first slice
|
||||
(`feat: add core authorization foundation`) established the schema, evaluator, store, and
|
||||
contracts. The second (`feat: add authorization bootstrap and HTTP enforcement`) added an explicit
|
||||
platform-owner bootstrap operation, a permission registry, and HTTP enforcement wired into
|
||||
`apps/api`. This ADR records the durable architectural decisions both slices established, since
|
||||
together they fix a shape future changes must not casually violate: what a permission is, who can
|
||||
grant one, how a platform owner is established, and how any of this reaches HTTP.
|
||||
|
||||
## Decision
|
||||
|
||||
### Core owns authorization independently of Hono, Surfaces, and modules
|
||||
|
||||
Authorization is a Core platform concern, exactly like identity. The schema
|
||||
(`core/src/authorization/schema.ts`), evaluator (`core/src/authorization/evaluator.ts`), store
|
||||
(`core/src/authorization/store.ts`), permission registry (`core/src/authorization/permissions.ts`),
|
||||
and bootstrap operation (`core/src/authorization/bootstrap.ts`) import nothing from `hono`,
|
||||
`apps/api`, or any module, and are independently testable without an HTTP transport. `apps/api`
|
||||
consumes Core's authorization surface the same way it already consumes identity: through explicit,
|
||||
injected composition (`apps/api/src/authorization-middleware.ts`,
|
||||
`apps/api/src/grants-routes.ts`), never the reverse. Modules cannot import Core at all under
|
||||
`scripts/check-boundaries.ts`, so this holds structurally, not just by convention.
|
||||
|
||||
### Permission IDs are exact, registered, and deny by default
|
||||
|
||||
A `PermissionId` (`@ekklesia/contracts`) is a validated, namespaced string
|
||||
(`core.authorization.grants.manage`), but being well-formed is not the same as being *registered*.
|
||||
`PermissionRegistry` (`core/src/authorization/permissions.ts`) is the trusted, immutable,
|
||||
composition-time-built source of which permissions actually exist and what baseline each requires.
|
||||
Matching is always exact string equality — there is no wildcard, prefix, or namespace-inheritance
|
||||
matching anywhere in the evaluator or the store; a permission `core.authorization.grants.manage`
|
||||
never implies or is implied by `core.authorization.grants.*` or `core.authorization.*`. An unknown
|
||||
or malformed permission id is treated identically to a denied decision, never a distinct "this
|
||||
would work if declared" response — deny-by-default holds even for permissions nobody has declared
|
||||
yet.
|
||||
|
||||
Registration is enforced everywhere a permission id can originate from outside Core's own trusted
|
||||
route wiring, not only for the caller's own required permission. `requirePermission`
|
||||
(`apps/api/src/authorization-middleware.ts`) checks the registry for the permission a *route*
|
||||
requires; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately checks the registry
|
||||
for the *target* permission a caller is requesting be granted to someone else, since that value
|
||||
comes from the request body rather than route wiring. Without the second check, a caller with
|
||||
`core.authorization.grants.manage` could create a stored grant for a permission id that is merely
|
||||
well-formed, not registered — a dormant grant that would silently become effective the moment
|
||||
that identifier was registered later, with no further action or review at registration time. Both
|
||||
checks reject with the identical sanitized `403 {"code":"AUTHORIZATION_DENIED"}` an ordinary denial
|
||||
would return, and neither ever calls into the store on rejection.
|
||||
|
||||
### Permissions declare one trusted baseline: public, authenticated, or restricted
|
||||
|
||||
Every registered permission declares exactly one `PermissionBaseline` at registration time. The
|
||||
evaluator (`core/src/authorization/evaluator.ts`) still accepts a baseline as part of its own
|
||||
request shape — that is pass 1's evaluator contract, unchanged — but the only caller allowed to
|
||||
supply it in this repository is Core's own trusted registry lookup
|
||||
(`requirePermission` in `apps/api/src/authorization-middleware.ts`); no HTTP route reads a baseline
|
||||
from a request header, query parameter, or body. `public` always allows; `authenticated` requires a
|
||||
non-anonymous principal and nothing else; `restricted` requires an authenticated principal *and*
|
||||
either platform-owner authority or an active matching grant — the baseline alone never satisfies a
|
||||
restricted check.
|
||||
|
||||
### Unknown permissions and client-supplied baselines fail closed
|
||||
|
||||
Two independent fail-closed layers exist on purpose, not one relied on twice, and they check
|
||||
different things. The registry itself (`PermissionRegistry.get`/`.has`) returns `undefined`/`false`
|
||||
for anything malformed or unregistered — this is the only layer that knows what is *registered*.
|
||||
`requirePermission` treats a registry miss as an immediate sanitized `403`, before principal
|
||||
resolution or evaluation ever runs; `POST /api/v1/grants` performs the equivalent registry check
|
||||
for the target permission named in its body (see above). Separately, `createAuthorizationEvaluator`
|
||||
independently re-validates permission *shape* — it holds no registry and cannot recognize
|
||||
registration at all — denying (without throwing) any malformed or unrecognized
|
||||
`permission`/`baseline`/`scope` it receives. This shape check runs, and can deny, **before any
|
||||
baseline is allowed to return an allowed decision** — `public` and `authenticated` are not
|
||||
exempt, even though neither reads the permission value for any other reason — so a future caller
|
||||
that reaches the evaluator directly, bypassing the registry entirely (for example a different
|
||||
transport Core does not yet have), inherits the same fail-closed guarantee for malformed input
|
||||
rather than depending on a registry check having already run upstream. The evaluator's shape check
|
||||
and the registry's registration check are deliberately not merged into one: only the registry can
|
||||
answer "is this permission registered," and the evaluator must remain usable — and safe against
|
||||
malformed input — by a caller that has no registry to consult at all.
|
||||
|
||||
### Explicit and relationship-derived grants share persistence but have distinct source kinds
|
||||
|
||||
`ekklesia_core_grant` is one table for both explicit grants (an administrator naming a subject
|
||||
user, permission, and optional scope directly) and relationship-derived grants (a future module
|
||||
operation establishing access automatically from a relationship it owns, for example a roster
|
||||
assignment). A `source_kind` column, constrained to exactly `'explicit'` or `'relationship'`,
|
||||
distinguishes them at the row level; nothing else about how a grant is stored or evaluated differs
|
||||
by source kind. This lets both kinds share one evaluator code path and one audit trail while still
|
||||
letting HTTP enforcement treat them very differently (see below).
|
||||
|
||||
### Unscoped grants satisfy scoped or unscoped checks; scoped grants satisfy only the exact matching scope
|
||||
|
||||
`AuthorizationStore.hasActiveGrant` (`core/src/authorization/store.ts`) matches a grant whose
|
||||
`scope` column is `NULL` against *any* check, scoped or unscoped, but matches a grant with a
|
||||
non-null `scope` only against a check requesting that exact scope string. An unscoped grant is
|
||||
strictly broader than any scoped grant of the same permission; a scoped grant never leaks into an
|
||||
unscoped check for the same permission. There is no partial, prefix, or hierarchical scope
|
||||
matching.
|
||||
|
||||
### Revoked and expired grants never authorize
|
||||
|
||||
`hasActiveGrant`'s query requires `revoked_at IS NULL` and either no `expires_at` or
|
||||
`expires_at` in the future, evaluated against an injectable clock (`now`) rather than each caller's
|
||||
own wall-clock read. Revocation is soft: `AuthorizationStore.revokeGrant` sets `revoked_at` and the
|
||||
revoking actor rather than deleting the row, so the audit trail and the grant's own history remain
|
||||
queryable after revocation, but a revoked row can never again satisfy an authorization check.
|
||||
|
||||
### Platform-owner authority is represented separately from grants and is not a wildcard permission
|
||||
|
||||
`ekklesia_core_platform_owner` is its own table, not a synthetic all-permissions grant row.
|
||||
`createAuthorizationEvaluator` checks `platformOwners.isPlatformOwner(userId)` as an independent
|
||||
`OR` alongside `hasActiveGrant`, so owner status bypasses the grant/scope machinery entirely rather
|
||||
than being implemented as a grant with a special scope or permission value that other code could
|
||||
accidentally match against. This keeps "is a platform owner" and "has this specific permission"
|
||||
structurally distinct, so a future owner-management feature can reason about the one table without
|
||||
touching grant semantics.
|
||||
|
||||
### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically
|
||||
|
||||
There is no signup-order or first-user inference anywhere in this repository. The only way to
|
||||
establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id <id>
|
||||
--confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`,
|
||||
`core/src/authorization/bootstrap.ts`), never an HTTP endpoint. "Bootstrap has already been
|
||||
consumed" is defined as *any row ever created* in `ekklesia_core_platform_owner`, for any user,
|
||||
including one whose `revoked_at` is set — there is no path in this slice that re-opens bootstrap
|
||||
after a revocation; a later, separate owner-management feature (not part of this slice) is how a
|
||||
platform owner would ever be re-established. The single-use check, the target user's existence
|
||||
check, the owner-row insert, and its audit-row insert are one PostgreSQL transaction, serialized by
|
||||
a transaction-scoped advisory lock (`pg_advisory_xact_lock`, a key pair distinct from the migration
|
||||
runner's own lock) acquired as the transaction's first statement, so two concurrent bootstrap
|
||||
attempts can never both observe "no owner yet" — exactly one succeeds, proven against real
|
||||
PostgreSQL. Any failure path (nonexistent user, lost race, audit failure) rolls back the entire
|
||||
transaction, leaving no partial owner or bootstrap state.
|
||||
|
||||
### Identity and Authorization use typed Drizzle views over one physical PostgreSQL pool in apps/api
|
||||
|
||||
`apps/api/src/database.ts`'s `createApiDatabase` constructs exactly one validated `pg.Pool`
|
||||
(`core/src/database/pool.ts`) and wraps it with two independent typed Drizzle views
|
||||
(`createIdentityDrizzleView`, `createAuthorizationDrizzleView`) rather than owning two pools. This
|
||||
is additive: `createIdentityDrizzleDatabase` and `createAuthorizationDrizzleDatabase` (each owning
|
||||
a private pool) remain available and unaffected for callers — the bootstrap CLI included — that
|
||||
only need one domain and have no reason to share a connection. `apps/api` closes the one shared
|
||||
pool exactly once, on graceful shutdown and on any startup failure after the pool was already
|
||||
constructed.
|
||||
|
||||
### Cookie-authenticated authorization mutations require an exact trusted Origin
|
||||
|
||||
`requireTrustedOrigin` (`apps/api/src/authorization-middleware.ts`) requires an exact match against
|
||||
the same trusted-origin set already used for CORS and Better Auth's own checks (ADR 0011, ADR
|
||||
0012) on every grant-mutation route. This is deliberately a server-side check distinct from CORS:
|
||||
CORS response headers only stop a browser from *reading* a cross-origin response, not from
|
||||
*sending* the state-changing request in the first place. A missing or untrusted origin is rejected
|
||||
before the permission check or any state change. Non-browser, non-cookie automation (service
|
||||
accounts/API tokens) has no way to satisfy this check and is explicitly deferred, not silently
|
||||
exempted.
|
||||
|
||||
### Generic HTTP grant management handles only explicit grants
|
||||
|
||||
`POST /api/v1/grants` always sets `source_kind` to `'explicit'` server-side — the request body has
|
||||
no field that can influence it — and `POST /api/v1/grants/:grantId/revoke` always filters its
|
||||
mutation to `source_kind = 'explicit'`. A relationship-derived grant's id, presented to the revoke
|
||||
route, matches zero rows and returns the same `404` a nonexistent grant id would, indistinguishably
|
||||
from the caller's perspective. The generic HTTP API also never accepts `actor`, `sourceKind`,
|
||||
`sourceRef`, any audit field, a grant id, `createdAt`, or `revokedAt` in a mutation body: the
|
||||
allowlist rejects the entire request if any other key is present, so a well-formed-looking request
|
||||
cannot smuggle a server-derived field past validation.
|
||||
|
||||
### Relationship-derived grants may be created or revoked only by the trusted module operation owning that relationship
|
||||
|
||||
No code path in this repository creates a `source_kind = 'relationship'` grant yet — there is no
|
||||
roster or similar module operation in this slice — but the architectural boundary is fixed here:
|
||||
when that operation exists, it is the only trusted caller for that grant's lifecycle, exercised
|
||||
through `AuthorizationStore` directly (as pass 2's own integration tests do to seed one), never
|
||||
through the generic `/api/v1/grants` HTTP surface. Nothing about this ADR assumes what that future
|
||||
module operation looks like — only that it does not go through the explicit-grant API.
|
||||
|
||||
### Deferred
|
||||
|
||||
Roles, groups, teams, and tenants; an owner-management HTTP endpoint (revoking or re-granting
|
||||
platform-owner status over HTTP); service accounts and API tokens for non-browser automation; and
|
||||
module permission discovery/registration (a mechanism for a module to contribute its own
|
||||
declarations to a combined permission registry) are all deliberately out of scope for Phase 4C and
|
||||
remain later work.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- Representing platform-owner status as a special grant row (a synthetic `*` permission, or a
|
||||
reserved scope value meaning "everything") was rejected: it would make "is this row an owner
|
||||
grant or a real permission grant" a runtime distinction every future grant-handling code path
|
||||
would need to remember to check, rather than a structural one enforced by table shape.
|
||||
- Implementing bootstrap as "check for an existing owner, then call the same `grantPlatformOwner`
|
||||
function grants already use" was rejected: composing two separately-transacted operations leaves
|
||||
a window between the check and the grant where a concurrent caller could observe the same "no
|
||||
owner yet" state, which is exactly the race a single-use bootstrap must not allow. A dedicated,
|
||||
single-transaction operation closes that window structurally instead of relying on discipline at
|
||||
every call site.
|
||||
- Allowing the generic explicit-grant HTTP API to also manage relationship-derived grants (with an
|
||||
optional `sourceKind` field in the request body) was rejected: it would let any caller holding
|
||||
`core.authorization.grants.manage` silently unwind access a module relationship established for
|
||||
reasons the generic API has no way to evaluate — for example, revoking a roster-derived grant
|
||||
without the roster module ever knowing its own invariant was violated.
|
||||
- A second physical PostgreSQL pool dedicated to authorization in `apps/api` (mirroring identity's
|
||||
own private-pool factory) was rejected in favor of one shared pool: `apps/api` is one process
|
||||
with one connection budget, and two independently-sized pools would double configured connection
|
||||
limits for no isolation benefit within a single process.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every HTTP route that needs authorization now goes through one reusable middleware
|
||||
(`requirePermission`) backed by one trusted registry, rather than each route independently deciding
|
||||
how to check a permission. Establishing a platform owner is a deliberate, auditable, one-time
|
||||
operator action with no ambiguity about who holds it or how it was granted. Future work extending
|
||||
authorization — module-declared permissions, relationship-derived grants, roles — has a fixed shape
|
||||
to extend rather than a green field: new permissions register through the same
|
||||
`createPermissionRegistry` constructor, new grant sources use the existing `source_kind` column
|
||||
and audit table, and any new HTTP surface reuses `requirePermission`/`requireTrustedOrigin` rather
|
||||
than reimplementing enforcement. No identity behavior, route, or response shape from ADR 0010,
|
||||
0011, or 0012 changed.
|
||||
@@ -21,3 +21,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
|
||||
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
|
||||
- [0012: Production-equivalent identity origin/CSRF protection under test](0012-identity-origin-csrf-protection-under-test.md) — Accepted
|
||||
- [0013: Core authorization, permission registry, and platform-owner bootstrap](0013-core-authorization-and-owner-bootstrap.md) — Accepted
|
||||
|
||||
+30
-19
@@ -9,6 +9,8 @@ A follow-up corrective slice hardened this transport (sanitized Better Auth logg
|
||||
production-equivalent origin/CSRF protection under the test runner, bounded identity database
|
||||
timeouts, idempotent shutdown, and pinned password-length boundaries) without changing any of the
|
||||
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md).
|
||||
Phase 4C adds Core's authorization foundation and, on top of this transport, a platform-owner
|
||||
bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
@@ -54,6 +56,7 @@ No real values are shown here — configure per environment and never commit the
|
||||
| --- | --- | --- |
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Better Auth's own request/response shapes pass through unmodified — see [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for the exact endpoints (`/sign-up/email`, `/sign-in/email`, `/sign-out`, `/get-session`, ...). |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||
@@ -101,11 +104,12 @@ exist or treats an empty database as special. Baseline authenticated status is w
|
||||
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
|
||||
saved content, preferences) — none of those features are implemented in this PR.
|
||||
|
||||
Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit,
|
||||
auditable operator command that grants an *existing* identity platform-owner authority — signup
|
||||
order is never part of that decision. Future **relationship-derived access** (for example, a
|
||||
person added to a volunteer roster automatically gaining narrow, reversible read access to that
|
||||
roster) belongs to a later roster slice, not this one.
|
||||
Explicit **platform-owner bootstrap** is now available as a local operator command — see
|
||||
[Authorization](authorization.md#platform-owner-bootstrap) — and grants an *existing* identity
|
||||
platform-owner authority; signup order is never part of that decision. Future
|
||||
**relationship-derived access** (for example, a person added to a volunteer roster automatically
|
||||
gaining narrow, reversible read access to that roster) belongs to a later roster slice, not this
|
||||
one.
|
||||
|
||||
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
|
||||
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
|
||||
@@ -204,15 +208,19 @@ abuse-control design.
|
||||
|
||||
## Database connection limits
|
||||
|
||||
`apps/api/src/server.ts` configures the identity PostgreSQL pool (`createIdentityDrizzleDatabase`,
|
||||
`core/src/identity/database.ts`) with both a connection timeout and an operation timeout (10
|
||||
seconds each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts`
|
||||
already uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does
|
||||
nothing once a query is already running on one. The operation timeout additionally sets `pg`'s
|
||||
`query_timeout` and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than
|
||||
holding a pool connection (and its caller) indefinitely. An invalid timeout (non-integer, zero,
|
||||
negative, or larger than PostgreSQL's own maximum) is rejected as configuration error before any
|
||||
pool is constructed, sanitized the same way an invalid connection string already is.
|
||||
`apps/api/src/server.ts` configures the one shared PostgreSQL pool (`createApiDatabase`,
|
||||
`apps/api/src/database.ts`, built on `createValidatedPostgresPool` from
|
||||
`core/src/database/pool.ts`) with both a connection timeout and an operation timeout (10 seconds
|
||||
each), the same bounded, validated pattern `core/src/migrations/postgres-database.ts` already
|
||||
uses. `connectionTimeoutMillis` alone only bounds *establishing* a connection; it does nothing once
|
||||
a query is already running on one. The operation timeout additionally sets `pg`'s `query_timeout`
|
||||
and `statement_timeout`, so a hung or unexpectedly slow query is aborted rather than holding a pool
|
||||
connection (and its caller) indefinitely. An invalid timeout (non-integer, zero, negative, or
|
||||
larger than PostgreSQL's own maximum) is rejected as configuration error before any pool is
|
||||
constructed, sanitized the same way an invalid connection string already is. Both the identity and
|
||||
authorization domains read from this one pool through independent typed Drizzle views — see
|
||||
[Authorization](authorization.md#shared-database-composition) for why there is exactly one pool,
|
||||
not two.
|
||||
|
||||
## Graceful shutdown
|
||||
|
||||
@@ -221,7 +229,9 @@ executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()
|
||||
shared promise the first time it is called: every caller, concurrent or sequential, awaits the
|
||||
exact same listener-drain-then-pool-close operation, and that operation runs exactly once no matter
|
||||
how many times `shutdown()` is called. It stops the HTTP listener from accepting new connections and
|
||||
lets in-flight requests finish, then closes the PostgreSQL pool. The executable entry point's
|
||||
lets in-flight requests finish, then closes the one shared PostgreSQL pool (identity and
|
||||
authorization both read from it — see
|
||||
[Authorization](authorization.md#shared-database-composition)). The executable entry point's
|
||||
`SIGINT`/`SIGTERM` wiring is extracted into `installShutdownSignalHandlers(running, exit)` — the
|
||||
same function production composition uses, but independently testable with a fake `running`/`exit`
|
||||
so a test can trigger "a signal arrived" without sending a real process signal or invoking the real
|
||||
@@ -268,7 +278,8 @@ Cookie: better-auth.session_token=<redacted, now revoked>
|
||||
|
||||
## Next phase
|
||||
|
||||
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
|
||||
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
|
||||
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
|
||||
that does not exist yet.
|
||||
Phase 4C, pass 3: module discovery/Module SDK permission registration, roles/groups/teams/tenants,
|
||||
an owner-management HTTP endpoint, service accounts/API tokens for non-browser automation, and
|
||||
reversible relationship-derived access itself. See [Authorization](authorization.md) for what this
|
||||
pass already added (permission registry, platform-owner bootstrap, explicit-grant HTTP API). Not a
|
||||
Dashboard Surface, which still depends on an authorization model that is only now getting started.
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
# Authorization
|
||||
|
||||
Phase 4C establishes Core's authorization foundation and, in a second slice, wires it into
|
||||
`apps/api` over HTTP. This page covers both: the schema/evaluator/store/registry Core owns, the
|
||||
platform-owner bootstrap operation, and the enforcement/endpoints `apps/api` exposes on top of it.
|
||||
See [Identity](identity.md) for the identity foundation this builds on, [API](api.md) for the
|
||||
HTTP transport it is mounted into, and [ADR
|
||||
0013](../architecture/decisions/0013-core-authorization-and-owner-bootstrap.md) for the durable
|
||||
architectural decisions this page describes in more implementation detail.
|
||||
|
||||
## What this is, and is not
|
||||
|
||||
After this slice, an operator can bootstrap exactly one platform owner for a deployment, a
|
||||
platform owner (or anyone they delegate to) can create, list, and revoke *explicit* authorization
|
||||
grants over HTTP, and every enforced route fails closed on infrastructure trouble instead of
|
||||
silently allowing or denying. It is **not** possible to manage roles, groups, teams, or tenants;
|
||||
there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status is not
|
||||
exposed at all in this slice); there are no service accounts or API tokens, so only
|
||||
cookie-authenticated browser callers can reach the mutation routes; and relationship-derived
|
||||
grants (for example, automatic access from a roster assignment) cannot yet be created by anything
|
||||
in this repository — the generic grants API structurally cannot create or revoke them either.
|
||||
|
||||
## Core authorization schema and store
|
||||
|
||||
Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`,
|
||||
`ekklesia_core_grant`, `ekklesia_core_authorization_audit`; `AuthorizationEvaluator`,
|
||||
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
|
||||
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`,
|
||||
`evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the
|
||||
permission registry, the bootstrap operation, and HTTP enforcement.
|
||||
|
||||
## Permission registry
|
||||
|
||||
`core/src/authorization/permissions.ts` defines the smallest possible registry: an immutable,
|
||||
composition-time-built `PermissionRegistry` distinguishing a *registered declaration*
|
||||
(`{id, baseline, description}`) from a merely well-formed `PermissionId` string. Two properties
|
||||
matter:
|
||||
|
||||
- **Duplicate declarations fail at composition time.** `createPermissionRegistry` throws
|
||||
`PermissionRegistryError` (`PERMISSION_REGISTRY_DUPLICATE_DECLARATION`) if the same permission id
|
||||
is declared twice — this can only happen while wiring the registry itself, not at request time.
|
||||
- **Unknown permissions fail closed at lookup time.** `registry.get(permissionId)` and
|
||||
`registry.has(permissionId)` both re-validate their input (`safeParsePermissionId` internally)
|
||||
and return `undefined`/`false` for anything malformed or simply not registered — there is no
|
||||
"trust this permission id because it looks right" path.
|
||||
|
||||
`CORE_PERMISSION_REGISTRY` is Core's own registry, currently declaring exactly one permission:
|
||||
|
||||
| Permission | Baseline | Description |
|
||||
| --- | --- | --- |
|
||||
| `core.authorization.grants.manage` | `restricted` | Create, list, and revoke another user's explicit authorization grants. |
|
||||
|
||||
Module-owned permission declarations and a discovery/composition path for them are out of scope
|
||||
for this slice (deferred to the Module SDK work).
|
||||
|
||||
### Two layers, not one: shape versus registration
|
||||
|
||||
`createAuthorizationEvaluator` (`core/src/authorization/evaluator.ts`) has no registry and is
|
||||
never given one — it independently re-validates `permission`, `baseline`, and `scope` as a pure
|
||||
shape check (`safeParsePermissionId`/`permissionBaselineSchema`/`safeParseAuthorizationScope`) and
|
||||
denies a malformed value under **every** baseline, including `public` and `authenticated`, before
|
||||
any baseline is allowed to return an allowed decision. This is defense in depth for a future
|
||||
caller that reaches the evaluator directly, not a substitute for the registry: the evaluator has
|
||||
no way to tell a well-formed-but-unregistered permission id (for example a plausible-looking
|
||||
`calendar.events.read` that nothing has ever declared) from a genuinely registered one — it can
|
||||
only reject what is not even shaped like a `PermissionId` at all.
|
||||
|
||||
*Registration* — "does this permission actually exist" — is exclusively the registry's
|
||||
responsibility, consulted by every trusted caller before the evaluator ever runs:
|
||||
`requirePermission` (`apps/api/src/authorization-middleware.ts`) looks up the *caller's own*
|
||||
required permission (for example `core.authorization.grants.manage`, always a hardcoded route
|
||||
constant, never client-supplied) in the registry before resolving a principal or evaluating
|
||||
anything; `POST /api/v1/grants` (`apps/api/src/grants-routes.ts`) separately looks up the *target*
|
||||
permission being granted — the one named in the request body — in the same registry, after body
|
||||
shape-validation and before ever calling `store.createGrant`, so a caller cannot create a dormant
|
||||
grant for an invented permission id that might later become effective the moment someone actually
|
||||
registers it. A malformed target permission (fails `PermissionId` shape validation) still resolves
|
||||
to the body-level `400 {"code":"INVALID_REQUEST"}` documented below, unchanged; a well-formed but
|
||||
unregistered target permission instead receives the identical sanitized
|
||||
`403 {"code":"AUTHORIZATION_DENIED"}` a genuinely denied caller would, so that response never
|
||||
reveals whether the caller was denied or the permission was simply never registered.
|
||||
|
||||
## Platform-owner bootstrap
|
||||
|
||||
There is no "first user becomes owner" inference anywhere in this repository, and no HTTP
|
||||
endpoint exposes bootstrap. The only way to establish a platform owner is the local CLI command:
|
||||
|
||||
```bash
|
||||
pnpm migrations:apply # the database must already be migrated
|
||||
DATABASE_URL=postgresql://... pnpm authorization:bootstrap-owner -- \
|
||||
--user-id <existing-ekklesia-user-id> \
|
||||
--confirm-user-id <same-id>
|
||||
```
|
||||
|
||||
`DATABASE_URL` is read only when the command actually executes (never merely by importing the CLI
|
||||
module — proved by `core/tests/cli-bootstrap-platform-owner.test.ts`), and it is never printed,
|
||||
logged, or included in any error. `--user-id` and `--confirm-user-id` must match exactly, checked
|
||||
before the id is even parsed as a valid `EkklesiaUserId` — this command never infers a target from
|
||||
signup order or "the only user in the database"; the operator must name the exact, opaque id
|
||||
twice.
|
||||
|
||||
### Bootstrap is permanently single-use
|
||||
|
||||
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated,
|
||||
Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then
|
||||
call the existing grant function." "Already consumed" means **any row has ever existed** in
|
||||
`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no
|
||||
owner-management flow in this slice that clears that state; a revoked owner staying revoked (and
|
||||
bootstrap staying consumed) is intentional here.
|
||||
|
||||
The single-use check, the target user's existence check, the owner-grant insert, and its audit
|
||||
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a
|
||||
transaction-scoped advisory lock:
|
||||
|
||||
```sql
|
||||
select pg_advisory_xact_lock(1500000001, 1500000002)
|
||||
```
|
||||
|
||||
This lock key pair is distinct from the migration runner's own advisory lock
|
||||
(`core/src/migrations/postgres-database.ts`), so bootstrap serialization can never collide with or
|
||||
be blocked by unrelated migration locking. `pg_advisory_xact_lock` releases automatically on
|
||||
commit or rollback, so no manual unlock path is needed even when the transaction fails partway.
|
||||
Two concurrent bootstrap attempts serialize on this lock: the second caller blocks until the
|
||||
first's transaction commits or rolls back, then re-evaluates the "does any owner row exist" check
|
||||
against now-current state — so exactly one of two concurrent attempts ever succeeds, proved live
|
||||
against real PostgreSQL in `core/tests/authorization-bootstrap.integration.ts`
|
||||
(`Promise.allSettled` on two simultaneous calls; asserts exactly one `fulfilled`, exactly one
|
||||
`rejected` with `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`, exactly one owner row, exactly one
|
||||
audit row).
|
||||
|
||||
A failed target-user lookup, an audit-insert failure, or losing the concurrency race all roll back
|
||||
the entire transaction — there is no partial state where an owner row exists without its audit
|
||||
entry, or vice versa.
|
||||
|
||||
The audit entry for a bootstrap grant always uses a fixed, sanitized, non-user actor identity —
|
||||
never derived from any caller input — so an auditor can tell a given `platform_owner.grant` row
|
||||
came specifically from this command rather than from a future owner-management flow:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `actor_type` | `system` |
|
||||
| `actor_id` | `authorization-bootstrap-cli` |
|
||||
|
||||
### Error codes
|
||||
|
||||
`PlatformOwnerBootstrapError` never carries the underlying database error, SQL text, host, port,
|
||||
path, or stack — only a stable `code` and a fixed message:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). |
|
||||
| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
|
||||
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
|
||||
|
||||
## HTTP authorization enforcement
|
||||
|
||||
`apps/api/src/authorization-middleware.ts` exports `requirePermission(options, permissionId)`, a
|
||||
Hono middleware factory reused by every protected route:
|
||||
|
||||
1. Looks up `permissionId` in the trusted, server-side `PermissionRegistry` (never a
|
||||
client-supplied baseline) — an unregistered permission id fails closed with `403` before the
|
||||
principal is even resolved. In today's wiring only hard-coded registered constants are ever
|
||||
passed by route code, but the check stays live per-request as defense in depth rather than
|
||||
being trusted once at composition time.
|
||||
2. Resolves the principal exclusively through the same server-side identity runtime
|
||||
`/api/v1/principal` already uses (`EkklesiaIdentityRuntime.resolvePrincipal`) — nothing here
|
||||
ever trusts a client-supplied claim about identity.
|
||||
3. Calls the injected `AuthorizationEvaluator` with the registry's baseline and permission for
|
||||
that principal (platform owners pass through the evaluator's owner bypass here — this is how
|
||||
delegation to `core.authorization.grants.manage` recipients also works, without a second code
|
||||
path).
|
||||
4. On success, stores the resolved `EkklesiaPrincipal` (Core's narrow `{kind, userId}` — never a
|
||||
Better Auth session/user object) on the Hono context via `c.set`, readable by the route through
|
||||
`getAuthorizedPrincipal(c)`. This is a deliberately narrow exception to the "no generalized
|
||||
session/user object in Hono context" concern [ADR
|
||||
0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) raised —
|
||||
only this already-public-safe principal shape is ever set.
|
||||
|
||||
A denied decision and an unregistered permission id both return a sanitized `403
|
||||
{"code":"AUTHORIZATION_DENIED"}`. A genuine infrastructure failure — principal resolution,
|
||||
evaluation, or the database underneath either — is never folded into that `403`; it returns a
|
||||
sanitized `503` instead (`PRINCIPAL_RESOLUTION_FAILED` or `AUTHORIZATION_EVALUATION_FAILED`),
|
||||
mirroring `/api/v1/principal`'s existing rule that "could not be determined" must never look like
|
||||
"no permission." Neither response ever reveals whether a grant or owner row exists for the caller.
|
||||
|
||||
### Origin enforcement for mutations
|
||||
|
||||
`requireTrustedOrigin(trustedOrigins)` requires an exact match against the same
|
||||
`EKKLESIA_IDENTITY_TRUSTED_ORIGINS` set the API already uses for CORS and Better Auth's own
|
||||
checks (see [API](api.md#origin-cors-and-cookies)) — a missing or untrusted `Origin` header is
|
||||
rejected with `403 {"code":"UNTRUSTED_ORIGIN"}` before the permission check or the route handler
|
||||
ever runs. This exists because CORS response headers alone are not CSRF protection: they only
|
||||
stop a browser from *reading* a cross-origin response, not from *sending* the state-changing
|
||||
request in the first place. Every grants mutation route (`POST /api/v1/grants`,
|
||||
`POST /api/v1/grants/:grantId/revoke`) requires this; the read-only list route does not.
|
||||
Non-browser, non-cookie automation (service accounts/API tokens) is out of scope for this slice
|
||||
and has no way to satisfy this check yet.
|
||||
|
||||
## Explicit-grant HTTP API
|
||||
|
||||
Mounted at the API's root by `apps/api/src/grants-routes.ts`. Every route requires
|
||||
`core.authorization.grants.manage`. All three routes only ever act on *explicit* grants — the
|
||||
generic HTTP API structurally cannot create or revoke a relationship-derived grant.
|
||||
|
||||
| Route | Origin required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
|
||||
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. |
|
||||
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
|
||||
|
||||
### `POST /api/v1/grants` request body
|
||||
|
||||
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`,
|
||||
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or
|
||||
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller
|
||||
cannot smuggle a server-derived field through a request that otherwise looks well-formed:
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
|
||||
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in `CORE_PERMISSION_REGISTRY` — see [Two layers, not one](#two-layers-not-one-shape-versus-registration). A malformed value is a `400`; a well-formed but unregistered one is a `403`. |
|
||||
| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. |
|
||||
| `expiresAt` | No | An ISO date string if present. |
|
||||
|
||||
The server always derives, and the request body can never supply: the grant id, `sourceKind`
|
||||
(always `"explicit"`), the actor (always the authenticated caller, via
|
||||
`createUserActor(principal.userId)`), and all audit/timestamp fields.
|
||||
|
||||
### Response shapes
|
||||
|
||||
A grant, wherever returned:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
"userId": "...",
|
||||
"permission": "core.authorization.grants.manage",
|
||||
"scope": null,
|
||||
"sourceKind": "explicit",
|
||||
"createdAt": "2026-07-23T00:00:00.000Z",
|
||||
"expiresAt": null,
|
||||
"revokedAt": null
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`.
|
||||
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
|
||||
|
||||
### Status-code mapping
|
||||
|
||||
| Status | When |
|
||||
| --- | --- |
|
||||
| `200` / `201` | Success. |
|
||||
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). |
|
||||
| `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. |
|
||||
| `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. |
|
||||
| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
|
||||
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
|
||||
|
||||
Every response sets `Cache-Control: no-store`.
|
||||
|
||||
## Shared database composition
|
||||
|
||||
`apps/api/src/database.ts`'s `createApiDatabase(databaseUrl, options)` is the one place `apps/api`
|
||||
touches PostgreSQL connection setup. It constructs exactly one validated, timeout-bounded
|
||||
`pg.Pool` (`createValidatedPostgresPool`, `@ekklesia/core/database`) and wraps it with two
|
||||
independent typed Drizzle views — `createIdentityDrizzleView(pool)` and
|
||||
`createAuthorizationDrizzleView(pool)` — rather than two separate pools. `close()` on the returned
|
||||
handle is the only thing that ever calls `pool.end()`; neither domain view owns or closes the pool
|
||||
itself. `apps/api/src/server.ts` calls `createApiDatabase` once during `startApi()`, builds the
|
||||
identity runtime from `.identity` and the `AuthorizationStore`/`AuthorizationEvaluator` from
|
||||
`.authorization` off that same handle, and closes the handle exactly once — on graceful shutdown,
|
||||
and on any startup failure after the pool was already constructed (mirroring the existing
|
||||
identity-only failure-cleanup path from [API](api.md#graceful-shutdown)).
|
||||
|
||||
The existing per-domain factories (`createIdentityDrizzleDatabase`,
|
||||
`createAuthorizationDrizzleDatabase`), which each construct and own a private pool, are
|
||||
unaffected and remain available for standalone/testable use — the CLI bootstrap command uses
|
||||
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs
|
||||
the authorization schema and has no reason to share a pool with anything else.
|
||||
|
||||
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
|
||||
`new Pool(...)` call for a full `startApi()` lifecycle (covering both identity and authorization
|
||||
usage), and exactly one `pool.end()` call regardless of how many concurrent `shutdown()` callers
|
||||
there are or whether the failure happened during startup.
|
||||
|
||||
## Tests
|
||||
|
||||
- `core/tests/authorization-permissions.test.ts` — registry construction, duplicate-declaration
|
||||
rejection, fail-closed lookup, `CORE_PERMISSION_REGISTRY` contents.
|
||||
- `core/tests/database-pool.test.ts` — shared pool validation/construction (mirrors the existing
|
||||
per-domain database tests).
|
||||
- `core/tests/authorization-bootstrap.test.ts` — import safety, `PlatformOwnerBootstrapError`
|
||||
behavior.
|
||||
- `core/tests/cli-bootstrap-platform-owner.test.ts` — the CLI never constructs a pool merely by
|
||||
being imported.
|
||||
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
|
||||
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
|
||||
still blocking reuse, and the concurrency scenario described above.
|
||||
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
|
||||
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior.
|
||||
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
|
||||
and malformed-field rejection, a well-formed but unregistered target permission rejected with
|
||||
`403` and `store.createGrant` never called, exact server-derived-argument assertions,
|
||||
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
|
||||
store-error mapping.
|
||||
- `apps/api/tests/lifecycle.test.ts` — shared-pool-closes-exactly-once coverage described above.
|
||||
- `core/tests/authorization-evaluator.test.ts` — includes a malformed permission id denying under
|
||||
every baseline (`public`, `authenticated`, `restricted`), never just `restricted`, and never
|
||||
consulting the grant or platform-owner ports for `public`/`authenticated`.
|
||||
- `apps/api/tests/api-http.integration.ts` (live PostgreSQL) — end-to-end: anonymous/non-owner
|
||||
denial, real bootstrap against the running API's own database, owner authority over the HTTP
|
||||
grants API, the owner delegating `core.authorization.grants.manage` to another account and that
|
||||
account then managing grants as a non-owner, a well-formed but unregistered target permission
|
||||
rejected with `403` (with no grant or audit row written) even for a caller with full
|
||||
grant-management authority, a create/list/revoke cycle with audit-trail verification (using
|
||||
`core.authorization.grants.manage` itself as the granted permission, since module permission
|
||||
registration does not exist yet), disallowed-field rejection, missing/untrusted-origin rejection,
|
||||
a real relationship-derived grant proven invisible to LIST and unreachable by REVOKE, and an
|
||||
infrastructure-failure scenario (database dropped out from under a live session) asserting a
|
||||
`503` rather than a false `403` or `200`.
|
||||
|
||||
Run the focused suites:
|
||||
|
||||
```bash
|
||||
pnpm --filter @ekklesia/core test
|
||||
pnpm --filter @ekklesia/api test
|
||||
pnpm test:migrations:integration # requires Docker
|
||||
```
|
||||
|
||||
## Next phase
|
||||
|
||||
Phase 4C, pass 3: deferred from this slice — module discovery/Module SDK permission
|
||||
registration, roles/groups/teams/tenants, an owner-management HTTP endpoint, service
|
||||
accounts/API tokens for non-browser automation, and relationship-derived grant creation itself
|
||||
(a roster or similar module operation, not the generic grants API).
|
||||
@@ -106,8 +106,9 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
1 migration · checksums valid
|
||||
2 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
|
||||
PostgreSQL
|
||||
○ Connection check skipped
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"api:start": "turbo run build && node --env-file-if-exists=.env apps/api/dist/server.js",
|
||||
"authorization:bootstrap-owner": "turbo run build && node --env-file-if-exists=.env core/dist/cli/bootstrap-platform-owner.js",
|
||||
"boundaries": "node scripts/check-boundaries.ts",
|
||||
"build": "turbo run build",
|
||||
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { z } from "zod";
|
||||
import type { EkklesiaUserId } from "./identity.js";
|
||||
import { parseEkklesiaUserId } from "./identity.js";
|
||||
|
||||
/**
|
||||
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and
|
||||
* `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through
|
||||
* `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's
|
||||
* Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque.
|
||||
*/
|
||||
export const grantIdSchema = z.string().trim().min(1).max(128).brand<"GrantId">();
|
||||
|
||||
export type GrantId = z.infer<typeof grantIdSchema>;
|
||||
|
||||
export function parseGrantId(input: unknown): GrantId {
|
||||
return grantIdSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParseGrantId(input: unknown): z.ZodSafeParseResult<GrantId> {
|
||||
return grantIdSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic, Core-opaque resource scope narrowing a grant or an authorization check to one
|
||||
* resource (for example a specific calendar event or media item). Core never interprets the
|
||||
* value beyond exact string equality — the owning module defines what a scope string means, if
|
||||
* anything. Not branded: unlike a permission or a grant, a scope carries no identity of its own
|
||||
* within Core; it is simply an opaque comparison key.
|
||||
*/
|
||||
export const authorizationScopeSchema = z.string().trim().min(1).max(256);
|
||||
|
||||
export type AuthorizationScope = z.infer<typeof authorizationScopeSchema>;
|
||||
|
||||
export function parseAuthorizationScope(input: unknown): AuthorizationScope {
|
||||
return authorizationScopeSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParseAuthorizationScope(
|
||||
input: unknown,
|
||||
): z.ZodSafeParseResult<AuthorizationScope> {
|
||||
return authorizationScopeSchema.safeParse(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every permission declaration has exactly one baseline. `public` allows any principal, anonymous
|
||||
* or authenticated. `authenticated` requires a valid authenticated principal and nothing more.
|
||||
* `restricted` requires an active matching grant or platform-owner authority — baseline alone
|
||||
* never satisfies it.
|
||||
*/
|
||||
export const permissionBaselineSchema = z.enum(["public", "authenticated", "restricted"]);
|
||||
|
||||
export type PermissionBaseline = z.infer<typeof permissionBaselineSchema>;
|
||||
|
||||
/**
|
||||
* Distinguishes a grant an operator/system created directly (`explicit`) from one a module
|
||||
* created as a side effect of its own business operation, attributed to some source the module
|
||||
* understands (`relationship`) — for example adding a person to a volunteer roster. Core never
|
||||
* interprets which relationship produced the grant; it only records the distinction and an
|
||||
* opaque source reference for audit purposes.
|
||||
*/
|
||||
export const grantSourceKindSchema = z.enum(["explicit", "relationship"]);
|
||||
|
||||
export type GrantSourceKind = z.infer<typeof grantSourceKindSchema>;
|
||||
|
||||
/** A Core-opaque reference to whatever produced a `relationship` grant (or, optionally, extra
|
||||
* context for an `explicit` one) — for example a module-defined identifier. Never interpreted by
|
||||
* Core, never a foreign key into another module's tables. */
|
||||
export const grantSourceRefSchema = z.string().trim().min(1).max(256);
|
||||
|
||||
export type GrantSourceRef = z.infer<typeof grantSourceRefSchema>;
|
||||
|
||||
const authorizationActorTypeSchema = z.enum(["user", "system"]);
|
||||
|
||||
export type AuthorizationActorType = z.infer<typeof authorizationActorTypeSchema>;
|
||||
|
||||
export interface AuthorizationUserActor {
|
||||
readonly type: "user";
|
||||
readonly userId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
export interface AuthorizationSystemActor {
|
||||
readonly type: "system";
|
||||
}
|
||||
|
||||
/**
|
||||
* Who performed a security-relevant authorization mutation (a grant or platform-owner change).
|
||||
* Mirrors `EkklesiaPrincipal`'s discriminated-union style. `system` covers Core-internal or
|
||||
* automated actions with no specific human actor (for example a future bootstrap command run by
|
||||
* an operator with no prior Ekklesia identity); it never means "unauthenticated" or "anonymous
|
||||
* caller" — every mutating call site chooses this deliberately.
|
||||
*/
|
||||
export type AuthorizationActor = AuthorizationUserActor | AuthorizationSystemActor;
|
||||
|
||||
export const SYSTEM_ACTOR: AuthorizationSystemActor = Object.freeze({ type: "system" });
|
||||
|
||||
export function createUserActor(userId: unknown): AuthorizationUserActor {
|
||||
return Object.freeze({ type: "user", userId: parseEkklesiaUserId(userId) });
|
||||
}
|
||||
|
||||
export function isUserActor(actor: AuthorizationActor): actor is AuthorizationUserActor {
|
||||
return actor.type === "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* The only public result of an authorization check. Deliberately minimal and frozen: it never
|
||||
* carries a reason, a matched grant, a permission echo, or any other detail a caller could use to
|
||||
* infer another user's grants or Core's internal evaluation path. `AUTHORIZATION_ALLOWED` and
|
||||
* `AUTHORIZATION_DENIED` are the only two values ever produced.
|
||||
*/
|
||||
export interface AuthorizationDecision {
|
||||
readonly allowed: boolean;
|
||||
}
|
||||
|
||||
export const AUTHORIZATION_ALLOWED: AuthorizationDecision = Object.freeze({ allowed: true });
|
||||
export const AUTHORIZATION_DENIED: AuthorizationDecision = Object.freeze({ allowed: false });
|
||||
|
||||
export function isAuthorizationAllowed(decision: AuthorizationDecision): boolean {
|
||||
return decision.allowed;
|
||||
}
|
||||
@@ -16,11 +16,23 @@ export const capabilityIdSchema = z
|
||||
.max(160)
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced capability identifier.");
|
||||
|
||||
/**
|
||||
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId`: a plain
|
||||
* `string` is not assignable to `PermissionId` without passing through
|
||||
* `parsePermissionId`/`safeParsePermissionId`. The brand adds no runtime tag or wrapper — a value
|
||||
* still serializes as a plain string. This is the one permission-identifier type shared by module
|
||||
* manifest declarations (`moduleManifestSchema`'s `permissions` array) and Core's authorization
|
||||
* evaluator, so a permission a module declares and a permission Core evaluates are provably the
|
||||
* same identifier space. Namespacing (the required `.`-segments) is purely a naming convention for
|
||||
* readability and collision-avoidance — it never implies hierarchy or inheritance during
|
||||
* evaluation, and wildcard segments are not part of this format at all.
|
||||
*/
|
||||
export const permissionIdSchema = z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(160)
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.");
|
||||
.regex(namespacedIdentifierPattern, "Must be a lowercase dot-namespaced permission identifier.")
|
||||
.brand<"PermissionId">();
|
||||
|
||||
export const logicalBucketIdSchema = z
|
||||
.string()
|
||||
@@ -58,3 +70,11 @@ export type CapabilityId = z.infer<typeof capabilityIdSchema>;
|
||||
export type PermissionId = z.infer<typeof permissionIdSchema>;
|
||||
export type LogicalBucketId = z.infer<typeof logicalBucketIdSchema>;
|
||||
export type PackageRelativePath = z.infer<typeof packageRelativePathSchema>;
|
||||
|
||||
export function parsePermissionId(input: unknown): PermissionId {
|
||||
return permissionIdSchema.parse(input);
|
||||
}
|
||||
|
||||
export function safeParsePermissionId(input: unknown): z.ZodSafeParseResult<PermissionId> {
|
||||
return permissionIdSchema.safeParse(input);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./authorization.js";
|
||||
export * from "./identifiers.js";
|
||||
export * from "./identity.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AUTHORIZATION_ALLOWED,
|
||||
AUTHORIZATION_DENIED,
|
||||
type AuthorizationActor,
|
||||
type AuthorizationDecision,
|
||||
createUserActor,
|
||||
type GrantId,
|
||||
isAuthorizationAllowed,
|
||||
isUserActor,
|
||||
parseAuthorizationScope,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
type PermissionId,
|
||||
safeParseAuthorizationScope,
|
||||
safeParseGrantId,
|
||||
safeParsePermissionId,
|
||||
SYSTEM_ACTOR,
|
||||
} from "../src/index.js";
|
||||
|
||||
describe("PermissionId", () => {
|
||||
it("parses a valid lowercase dot-namespaced permission id", () => {
|
||||
expect(parsePermissionId("calendar.events.read")).toBe("calendar.events.read");
|
||||
});
|
||||
|
||||
it("is opaque at the type level: a plain string is not assignable without parsing", () => {
|
||||
// @ts-expect-error a bare string must not satisfy the branded PermissionId type.
|
||||
const notAllowed: PermissionId = "calendar.events.read";
|
||||
expect(notAllowed).toBe("calendar.events.read");
|
||||
});
|
||||
|
||||
it("rejects a non-namespaced, empty, oversized, or non-string permission id", () => {
|
||||
expect(() => parsePermissionId("read")).toThrow();
|
||||
expect(() => parsePermissionId("")).toThrow();
|
||||
expect(() => parsePermissionId("Calendar.Events.Read")).toThrow();
|
||||
expect(() => parsePermissionId("x".repeat(161))).toThrow();
|
||||
expect(() => parsePermissionId(42)).toThrow();
|
||||
expect(() => parsePermissionId(null)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a wildcard segment: there is no such thing as a wildcard permission", () => {
|
||||
expect(() => parsePermissionId("media.*")).toThrow();
|
||||
expect(() => parsePermissionId("media.*.read")).toThrow();
|
||||
expect(() => parsePermissionId("*")).toThrow();
|
||||
});
|
||||
|
||||
it("exposes a safe-parse variant that never throws", () => {
|
||||
expect(safeParsePermissionId("calendar.events.read").success).toBe(true);
|
||||
expect(safeParsePermissionId("*").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GrantId", () => {
|
||||
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => {
|
||||
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
expect(id).toBe("11111111-1111-1111-1111-111111111111");
|
||||
expect(() => parseGrantId("")).toThrow();
|
||||
expect(() => parseGrantId("x".repeat(129))).toThrow();
|
||||
});
|
||||
|
||||
it("exposes a safe-parse variant that never throws", () => {
|
||||
expect(safeParseGrantId("abc").success).toBe(true);
|
||||
expect(safeParseGrantId("").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationScope", () => {
|
||||
it("accepts a non-empty opaque scope and rejects empty/whitespace/oversized scope", () => {
|
||||
expect(parseAuthorizationScope("calendar-event:123")).toBe("calendar-event:123");
|
||||
expect(safeParseAuthorizationScope("").success).toBe(false);
|
||||
expect(safeParseAuthorizationScope(" ").success).toBe(false);
|
||||
expect(safeParseAuthorizationScope("x".repeat(257)).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationActor", () => {
|
||||
it("creates a frozen user actor carrying only the opaque user id", () => {
|
||||
const actor = createUserActor("user_123");
|
||||
expect(actor).toEqual({ type: "user", userId: "user_123" });
|
||||
expect(Object.isFrozen(actor)).toBe(true);
|
||||
expect(isUserActor(actor)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an invalid user id when creating a user actor", () => {
|
||||
expect(() => createUserActor("")).toThrow();
|
||||
expect(() => createUserActor(42)).toThrow();
|
||||
});
|
||||
|
||||
it("exposes a frozen system actor singleton distinguishable from a user actor", () => {
|
||||
expect(SYSTEM_ACTOR).toEqual({ type: "system" });
|
||||
expect(Object.isFrozen(SYSTEM_ACTOR)).toBe(true);
|
||||
expect(isUserActor(SYSTEM_ACTOR)).toBe(false);
|
||||
});
|
||||
|
||||
it("discriminates actors on type without leaking extra fields", () => {
|
||||
const actors: AuthorizationActor[] = [SYSTEM_ACTOR, createUserActor("user_123")];
|
||||
for (const actor of actors) {
|
||||
expect(["user", "system"]).toContain(actor.type);
|
||||
}
|
||||
expect(Object.keys(SYSTEM_ACTOR)).toEqual(["type"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorizationDecision", () => {
|
||||
it("exposes exactly two frozen, minimal decision singletons", () => {
|
||||
expect(AUTHORIZATION_ALLOWED).toEqual({ allowed: true });
|
||||
expect(AUTHORIZATION_DENIED).toEqual({ allowed: false });
|
||||
expect(Object.isFrozen(AUTHORIZATION_ALLOWED)).toBe(true);
|
||||
expect(Object.isFrozen(AUTHORIZATION_DENIED)).toBe(true);
|
||||
expect(Object.keys(AUTHORIZATION_ALLOWED)).toEqual(["allowed"]);
|
||||
expect(Object.keys(AUTHORIZATION_DENIED)).toEqual(["allowed"]);
|
||||
});
|
||||
|
||||
it("never carries a reason, matched grant, permission echo, or any other detail", () => {
|
||||
const decisions: AuthorizationDecision[] = [AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED];
|
||||
for (const decision of decisions) {
|
||||
expect(Object.keys(decision)).not.toContain("reason");
|
||||
expect(Object.keys(decision)).not.toContain("grantId");
|
||||
expect(Object.keys(decision)).not.toContain("permission");
|
||||
expect(Object.keys(decision)).not.toContain("code");
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes a boolean helper matching the allowed field", () => {
|
||||
expect(isAuthorizationAllowed(AUTHORIZATION_ALLOWED)).toBe(true);
|
||||
expect(isAuthorizationAllowed(AUTHORIZATION_DENIED)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ export default defineConfig({
|
||||
hookTimeout: 30_000,
|
||||
include: [
|
||||
"core/tests/postgres-migration.integration.ts",
|
||||
"core/tests/authorization-store.integration.ts",
|
||||
"core/tests/authorization-bootstrap.integration.ts",
|
||||
"apps/api/tests/api-http.integration.ts",
|
||||
],
|
||||
passWithNoTests: false,
|
||||
|
||||
Reference in New Issue
Block a user