Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet.
144 lines
6.2 KiB
TypeScript
144 lines
6.2 KiB
TypeScript
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<AuthorizationDecision>;
|
|
}
|
|
|
|
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<AuthorizationDecision> => {
|
|
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;
|
|
},
|
|
});
|
|
}
|