feat: add authorization bootstrap and HTTP enforcement

Wires Core's identity and authorization domains into apps/api over one
shared, validated PostgreSQL pool; adds an explicit, single-use,
concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible
immutable permission registry with one Core-owned restricted permission
(core.authorization.grants.manage); and adds reusable Hono authorization
enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing
explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
ekklesia
2026-07-23 11:16:32 -05:00
parent 72e082ff2f
commit 1363608552
33 changed files with 2962 additions and 63 deletions
+115
View File
@@ -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");
}
}
+11
View File
@@ -105,3 +105,14 @@ export function createAuthorizationDrizzleDatabase(
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 });
}
+2
View File
@@ -1,4 +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";
+109
View File
@@ -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,
);
+51 -2
View File
@@ -14,7 +14,7 @@ import {
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
import { and, desc, eq, gt, isNull, or, sql } from "drizzle-orm";
import type { AuthorizationDrizzleDatabase } from "./database.js";
import type {
ActiveGrantQuery,
@@ -29,6 +29,7 @@ import {
export type AuthorizationStoreErrorCode =
| "AUTHORIZATION_GRANT_CREATE_FAILED"
| "AUTHORIZATION_GRANT_LIST_FAILED"
| "AUTHORIZATION_GRANT_LOOKUP_FAILED"
| "AUTHORIZATION_GRANT_NOT_FOUND"
| "AUTHORIZATION_GRANT_REVOKE_FAILED"
@@ -38,6 +39,7 @@ export type AuthorizationStoreErrorCode =
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.",
@@ -90,6 +92,18 @@ export interface GrantRecord {
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;
}
/**
@@ -103,6 +117,12 @@ export interface RevokeGrantInput {
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>;
}
@@ -193,7 +213,13 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
revokedByActorType: input.actor.type,
})
.where(
and(eq(ekklesiaCoreGrant.id, input.grantId), isNull(ekklesiaCoreGrant.revokedAt)),
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
),
)
.returning();
if (!row) {
@@ -243,6 +269,29 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
}
},
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,
+106
View File
@@ -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;
});
}
+1
View File
@@ -0,0 +1 @@
export * from "./pool.js";
+110
View File
@@ -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,
});
}
+11
View File
@@ -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
View File
@@ -1,4 +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";