feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one shared, validated PostgreSQL pool; adds an explicit, single-use, concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible immutable permission registry with one Core-owned restricted permission (core.authorization.grants.manage); and adds reusable Hono authorization enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
@@ -0,0 +1,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");
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user