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
+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,