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,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,
|
||||
);
|
||||
Reference in New Issue
Block a user