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:
@@ -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