feat: add core authorization foundation
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
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; a malformed or unrecognizable
|
||||
* value denies rather than throwing — "unknown or malformed permissions fail closed." */
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const parsedPermission = safeParsePermissionId(request.permission);
|
||||
if (!parsedPermission.success) {
|
||||
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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user