import { AUTHORIZATION_ALLOWED, AUTHORIZATION_DENIED, type AuthorizationDecision, type AuthorizationScope, type EkklesiaPrincipal, isAuthenticatedPrincipal, permissionBaselineSchema, safeParseAuthorizationScope, safeParsePermissionId, } from "@ekklesia/contracts"; import type { AuthorizationGrantPort, AuthorizationPlatformOwnerPort } from "./ports.js"; export type AuthorizationEvaluationErrorCode = "AUTHORIZATION_EVALUATION_FAILED"; /** Never carries the underlying port error, database error, cause, stack, or any other internal * detail — defense in depth on top of whatever sanitization the injected ports themselves already * perform (`core/src/authorization/store.ts` already sanitizes), exactly like * `core/src/identity/runtime.ts`'s `resolvePrincipal` re-sanitizes on top of Better Auth's own * error handling rather than trusting it exclusively. */ export class AuthorizationEvaluationError extends Error { readonly code: AuthorizationEvaluationErrorCode; constructor() { super("The authorization decision could not be evaluated."); this.name = "AuthorizationEvaluationError"; this.code = "AUTHORIZATION_EVALUATION_FAILED"; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } export interface AuthorizeRequest { readonly principal: EkklesiaPrincipal; /** Validated internally via the branded `PermissionId` schema *before any baseline can return an * allowed decision* — a malformed or unrecognizable value denies rather than throwing, for * `public` and `authenticated` baselines exactly as much as `restricted`. This is shape * validation only: the evaluator has no registry and cannot tell a well-formed-but-unregistered * permission from one that is actually registered. Only a trusted caller consulting a * `PermissionRegistry` (`core/src/authorization/permissions.ts`) before calling `authorize` can * make that distinction — see that module and `apps/api/src/authorization-middleware.ts`. */ readonly permission: unknown; /** Validated internally against the known `public`/`authenticated`/`restricted` set; anything * else denies rather than throwing, for the same fail-closed reason. */ readonly baseline: unknown; /** Optional; validated internally when present. Absent means an unscoped check. */ readonly scope?: unknown; } export interface AuthorizationEvaluator { authorize(request: AuthorizeRequest): Promise; } export interface CreateAuthorizationEvaluatorOptions { readonly grants: AuthorizationGrantPort; readonly platformOwners: AuthorizationPlatformOwnerPort; /** Injectable clock so expiration evaluation is deterministic and testable. Defaults to the * real wall clock; production composition must leave this unset. */ readonly now?: () => Date; } /** * Core's Hono-independent, deny-by-default authorization evaluator. It is the only place * Ekklesia decides whether a principal may exercise a permission, and it never trusts anything * the caller supplies beyond `principal`/`permission`/`baseline`/`scope` — there is no field for a * caller to assert "I am the owner" or "I already have this grant." Owner and grant state are * always re-derived from the injected ports for every call. * * A genuine infrastructure failure from either port (for example a database outage) is * deliberately NOT swallowed into a `denied` decision here: it propagates to the caller as a * thrown, sanitized `AuthorizationEvaluationError`, exactly like Core's identity * `resolvePrincipal` never silently downgrades an outage to "anonymous." Only a genuinely * malformed or unrecognized permission/baseline/scope — a deterministic, expected input problem, * not an infrastructure failure — resolves to `denied` without throwing. */ export function createAuthorizationEvaluator( options: CreateAuthorizationEvaluatorOptions, ): AuthorizationEvaluator { const now = options.now ?? (() => new Date()); return Object.freeze({ authorize: async (request: AuthorizeRequest): Promise => { const parsedBaseline = permissionBaselineSchema.safeParse(request.baseline); if (!parsedBaseline.success) { return AUTHORIZATION_DENIED; } // Shape-validated before any baseline is allowed to return `allowed` — a malformed // permission id must deny under `public` and `authenticated` exactly as much as under // `restricted`, even though neither of the first two ever reads `parsedPermission.data`. // This is a pure, synchronous check: it never touches the grant or platform-owner ports, so // `public`/`authenticated` still resolve with no database lookup at all. const parsedPermission = safeParsePermissionId(request.permission); if (!parsedPermission.success) { return AUTHORIZATION_DENIED; } if (parsedBaseline.data === "public") { return AUTHORIZATION_ALLOWED; } if (parsedBaseline.data === "authenticated") { return isAuthenticatedPrincipal(request.principal) ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED; } // parsedBaseline.data === "restricted": requires an authenticated principal AND // (an active matching grant OR platform-owner authority). Baseline alone never satisfies it. if (!isAuthenticatedPrincipal(request.principal)) { return AUTHORIZATION_DENIED; } let scope: AuthorizationScope | undefined; if (request.scope !== undefined) { const parsedScope = safeParseAuthorizationScope(request.scope); if (!parsedScope.success) { return AUTHORIZATION_DENIED; } scope = parsedScope.data; } const userId = request.principal.userId; let isOwner: boolean; let hasGrant: boolean; try { [isOwner, hasGrant] = await Promise.all([ options.platformOwners.isPlatformOwner(userId), options.grants.hasActiveGrant({ now: now(), permission: parsedPermission.data, userId, ...(scope === undefined ? {} : { scope }), }), ]); } catch { throw new AuthorizationEvaluationError(); } return isOwner || hasGrant ? AUTHORIZATION_ALLOWED : AUTHORIZATION_DENIED; }, }); }