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