UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

688 lines (627 loc) 19.3 kB
type MaybePromise<T> = T | Promise<T>; /** * A policy decision that allows the requested ability. */ export type GateAllowedDecision = { allowed: true; }; /** * A policy decision that denies the requested ability. * * Use `reason`, `code`, and `details` to preserve structured denial context for * errors, audit logs, and tests. */ export type GateDeniedDecision = { allowed: false; reason?: string; code?: string; details?: unknown; }; /** * Normalized authorization decision returned by gate inspection. */ export type GateDecision = GateAllowedDecision | GateDeniedDecision; /** * Value a policy resolver may return. * * Returning `true`/`false` is convenient for simple policies. Return * `allow()`/`deny(...)` when the caller needs a denial reason, code, or * structured details. */ export type GatePolicyResult = boolean | GateDecision; /** * Function that decides whether a context can perform an ability. * * The first argument is always the application context. Policies that operate * on a record receive that record as their second argument. */ export type PolicyResolver = ( ...args: never[] ) => MaybePromise<GatePolicyResult>; /** * Typed collection of ability resolvers created by `definePolicy(...)`. */ export type PolicyDefinition< TPolicies extends Record<string, PolicyResolver> = Record< string, PolicyResolver >, > = { policies: TPolicies; }; /** * Infer the application context type from a policy resolver. */ export type PolicyContext<TResolver> = TResolver extends ( ctx: infer Ctx, ...args: never[] ) => MaybePromise<GatePolicyResult> ? Ctx : never; /** * Infer whether an ability needs a subject argument. */ export type PolicySubjectArgs<TResolver> = TResolver extends ( ...args: infer TArgs ) => MaybePromise<GatePolicyResult> ? TArgs extends [unknown, infer Subject] ? [subject: Subject] : [] : []; /** * One authorization check accepted by batch gate APIs. */ export type PolicyBatchCheck<TPolicies extends readonly PolicyDefinition[]> = { [TAbility in keyof PolicyMapFromDefinitions<TPolicies> & string]: readonly [ ability: TAbility, ...subject: PolicySubjectArgs< PolicyMapFromDefinitions<TPolicies>[TAbility] >, ]; }[keyof PolicyMapFromDefinitions<TPolicies> & string]; /** * Keyed authorization checks accepted by `inspectMany(...)` and `canMany(...)`. */ export type PolicyBatch<TPolicies extends readonly PolicyDefinition[]> = Record< string, PolicyBatchCheck<TPolicies> >; /** * Full decision map returned by `inspectMany(...)`. */ export type PolicyBatchDecisionMap< TBatch extends PolicyBatch<readonly PolicyDefinition[]>, > = { [TKey in keyof TBatch]: GateDecision; }; /** * Boolean decision map returned by `canMany(...)`. */ export type PolicyBatchBooleanMap< TBatch extends PolicyBatch<readonly PolicyDefinition[]>, > = { [TKey in keyof TBatch]: boolean; }; /** * Gate method that produced a policy decision observation. */ export type GateDecisionSource = | "can" | "inspect" | "authorize" | "canMany" | "inspectMany"; type UnionToIntersection<T> = ( T extends unknown ? (value: T) => void : never ) extends (value: infer U) => void ? U : never; /** * Merge the ability maps from multiple policy definitions. */ export type PolicyMapFromDefinitions< TPolicies extends readonly PolicyDefinition[], > = UnionToIntersection< TPolicies[number] extends PolicyDefinition<infer TPolicyMap> ? TPolicyMap : never >; /** * Infer the application context type shared by a list of policy definitions. */ export type PolicyContextFromDefinitions< TPolicies extends readonly PolicyDefinition[], > = PolicyContext< PolicyMapFromDefinitions<TPolicies>[keyof PolicyMapFromDefinitions<TPolicies>] >; /** * Gate bound to a specific application context. * * Apps commonly attach this to request context as `ctx.gate` so use cases can * call `ctx.gate.authorize("posts.update", post)` without passing `ctx` back * into every authorization call. */ export type BoundGate<TPolicies extends readonly PolicyDefinition[]> = { /** * Type-only marker that keeps `TPolicies` inferable from a bound gate. * Runtime bound gates never implement this method. Method syntax keeps the * marker bivariant so readonly and mutable policy tuples stay compatible. */ __policies?(policies: TPolicies): void; /** * Return only whether the ability is allowed. */ can<TAbility extends keyof PolicyMapFromDefinitions<TPolicies> & string>( ability: TAbility, ...subject: PolicySubjectArgs<PolicyMapFromDefinitions<TPolicies>[TAbility]> ): Promise<boolean>; /** * Return keyed boolean decisions for several abilities. */ canMany<const TBatch extends PolicyBatch<TPolicies>>( checks: TBatch, ): Promise<PolicyBatchBooleanMap<TBatch>>; /** * Return the full allow/deny decision without throwing. */ inspect<TAbility extends keyof PolicyMapFromDefinitions<TPolicies> & string>( ability: TAbility, ...subject: PolicySubjectArgs<PolicyMapFromDefinitions<TPolicies>[TAbility]> ): Promise<GateDecision>; /** * Return keyed allow/deny decisions for several abilities. */ inspectMany<const TBatch extends PolicyBatch<TPolicies>>( checks: TBatch, ): Promise<PolicyBatchDecisionMap<TBatch>>; /** * Return an allowed decision or throw for denied abilities. */ authorize< TAbility extends keyof PolicyMapFromDefinitions<TPolicies> & string, >( ability: TAbility, ...subject: PolicySubjectArgs<PolicyMapFromDefinitions<TPolicies>[TAbility]> ): Promise<GateAllowedDecision>; }; /** * Context shape contributed by `gate.attach(...)`. */ export type GateContext< TPolicies extends readonly PolicyDefinition[] = readonly PolicyDefinition[], > = { /** * Gate bound to the context that carries it. */ gate: BoundGate<TPolicies>; }; /** * App-facing authorization gate. * * The gate evaluates app-owned policies. It is not an authentication provider: * authenticate at the HTTP boundary first, then pass the resulting actor/user * data into policy context. */ export type GatePort< TContext, TPolicies extends readonly PolicyDefinition[] = readonly PolicyDefinition[], > = { /** * Bind this gate to a fixed context snapshot. * * This is the low-level primitive: the returned gate keeps evaluating * against the exact object it was bound to. Prefer `attach(...)` for app * context assembly so identity changes can never go stale. */ bind(ctx: TContext): BoundGate<TPolicies>; /** * Attach a live `gate` property to a context object. * * The gate is exposed through a getter that re-binds against the receiving * object on every access, so in-place updates to fields such as `actor` or * `tenant` are always observed. The property is non-enumerable on purpose: * spreading the context (`{ ...ctx }`) drops the gate instead of silently * carrying a stale identity, and the next `ctx.gate` access fails loudly. */ attach<C extends TContext & object>(ctx: C): C & GateContext<TPolicies>; /** * Return only whether the ability is allowed for a context. */ can<TAbility extends keyof PolicyMapFromDefinitions<TPolicies> & string>( ctx: TContext, ability: TAbility, ...subject: PolicySubjectArgs<PolicyMapFromDefinitions<TPolicies>[TAbility]> ): Promise<boolean>; /** * Return keyed boolean decisions for several abilities. */ canMany<const TBatch extends PolicyBatch<TPolicies>>( ctx: TContext, checks: TBatch, ): Promise<PolicyBatchBooleanMap<TBatch>>; /** * Return the full allow/deny decision for a context without throwing. */ inspect<TAbility extends keyof PolicyMapFromDefinitions<TPolicies> & string>( ctx: TContext, ability: TAbility, ...subject: PolicySubjectArgs<PolicyMapFromDefinitions<TPolicies>[TAbility]> ): Promise<GateDecision>; /** * Return keyed allow/deny decisions for several abilities. */ inspectMany<const TBatch extends PolicyBatch<TPolicies>>( ctx: TContext, checks: TBatch, ): Promise<PolicyBatchDecisionMap<TBatch>>; /** * Return an allowed decision or throw for denied abilities. */ authorize< TAbility extends keyof PolicyMapFromDefinitions<TPolicies> & string, >( ctx: TContext, ability: TAbility, ...subject: PolicySubjectArgs<PolicyMapFromDefinitions<TPolicies>[TAbility]> ): Promise<GateAllowedDecision>; }; /** * Hook used to convert a denied decision into an application-specific error. */ export type GateDenyHandler<TContext> = ( decision: GateDeniedDecision, params: { ctx: TContext; ability: string; subject?: unknown; }, ) => MaybePromise<Error | undefined>; /** * Best-effort policy decision observation emitted by a gate. * * Observers are for diagnostics and audit-style integrations only. They do not * participate in authorization control flow and thrown/rejected observer errors * are ignored. */ export type GateDecisionObservation<TContext> = { /** * Gate method that produced the decision. */ source: GateDecisionSource; /** * Key from a batch input object, when the decision came from a batch call. */ batchKey?: string; /** * Application context used for policy evaluation. */ ctx: TContext; /** * Ability being evaluated. */ ability: string; /** * Optional subject passed to the policy. */ subject?: unknown; /** * Normalized decision, when policy evaluation returned normally. */ decision?: GateDecision; /** * Error thrown by the policy resolver, when evaluation failed. */ error?: unknown; /** * Policy evaluation duration, excluding observer work. */ durationMs: number; /** * Correlation fields copied from the context when present. */ requestId?: string; traceId?: string; spanId?: string; parentSpanId?: string; traceparent?: string; }; /** * Best-effort observer called after each gate decision or resolver error. */ export type GateDecisionObserver<TContext> = ( observation: GateDecisionObservation<TContext>, ) => MaybePromise<void>; /** * Options for `createGate(...)`. */ export type CreateGateOptions< TContext, TPolicies extends readonly PolicyDefinition[], > = { /** * Policy definitions to register. */ policies: TPolicies; /** * Optional mapper for denied authorization decisions. */ onDeny?: GateDenyHandler<TContext>; /** * Optional best-effort observer for policy decisions. * * This hook is diagnostic only: it cannot change decisions or thrown errors. */ onDecision?: GateDecisionObserver<TContext>; }; /** * Default error thrown by `authorize(...)` when a policy denies access. */ export class GateAuthorizationError extends Error { readonly code: string; readonly status = 403; readonly details?: unknown; constructor(decision: GateDeniedDecision = deny()) { super(decision.reason ?? "Forbidden"); this.name = "GateAuthorizationError"; this.code = decision.code ?? "FORBIDDEN"; this.details = decision.details; } } /** * Create an explicit allow decision. * * @returns A normalized gate decision with `allowed: true`. */ export function allow(): GateAllowedDecision { return { allowed: true }; } /** * Create an explicit deny decision. * * @example * ```ts * return deny("Only owners can edit this post"); * ``` * * @param reasonOrDecision - Optional reason string or structured denial data. * @returns A normalized gate decision with `allowed: false`. */ export function deny( reasonOrDecision?: string | Omit<GateDeniedDecision, "allowed">, ): GateDeniedDecision { if (typeof reasonOrDecision === "string") { return { allowed: false, reason: reasonOrDecision }; } return { allowed: false, ...reasonOrDecision, }; } /** * Define a typed group of authorization policies. * * Keep policy definitions near the feature that owns the business rule. The * returned definition is registered with `createGate(...)`. * * @example * ```ts * export const postPolicy = definePolicy({ * "posts.update": (ctx, post: Post) => post.authorId === ctx.actor.id, * }); * ``` * * @param policies - Ability resolver map keyed by stable ability names. * @returns A typed policy definition for registration with `createGate(...)`. */ export function definePolicy< const TPolicies extends Record<string, PolicyResolver>, >(policies: TPolicies): PolicyDefinition<TPolicies> { return { policies }; } /** * Create an authorization gate from app-owned policy definitions. * * Register the gate as a port, then let the server context blueprint attach * it: `context: { gate: (ports) => ports.gate, ... }`. Use cases can then call * `ctx.gate.authorize(...)` for business authorization. * * @param options - Policy definitions and optional denial mapper. * @returns A gate port that can evaluate registered abilities. */ export function createGate< TContext, const TPolicies extends readonly PolicyDefinition[], >( options: CreateGateOptions<TContext, TPolicies>, ): GatePort<TContext, TPolicies> { const registry = new Map<string, PolicyResolver>(); const observeDecision = createDecisionObserver(options.onDecision); for (const definition of options.policies) { for (const [ability, resolver] of Object.entries(definition.policies)) { if (registry.has(ability)) { throw new Error(`Policy ability "${ability}" is already registered.`); } registry.set(ability, resolver); } } async function evaluate( ctx: TContext, ability: string, subject?: unknown, source: GateDecisionSource = "inspect", batchKey?: string, ): Promise<GateDecision> { const startedAt = policyNow(); const resolver = registry.get(ability); if (!resolver) { const decision = deny({ reason: `No policy registered for "${ability}".`, code: "POLICY_NOT_FOUND", }); observeDecision({ source, batchKey, ctx, ability, subject, decision, durationMs: policyNow() - startedAt, }); return decision; } try { const result = await resolver(ctx as never, subject as never); const decision = normalizeDecision(result); observeDecision({ source, batchKey, ctx, ability, subject, decision, durationMs: policyNow() - startedAt, }); return decision; } catch (error) { observeDecision({ source, batchKey, ctx, ability, subject, error, durationMs: policyNow() - startedAt, }); throw error; } } async function authorize( ctx: TContext, ability: string, subject?: unknown, ): Promise<GateAllowedDecision> { const decision = await evaluate(ctx, ability, subject, "authorize"); if (decision.allowed) return decision; const thrown = await options.onDeny?.(decision, { ctx, ability, subject }); throw thrown ?? new GateAuthorizationError(decision); } async function inspectMany<const TBatch extends PolicyBatch<TPolicies>>( ctx: TContext, checks: TBatch, source: "canMany" | "inspectMany", ): Promise<PolicyBatchDecisionMap<TBatch>> { const decisions: Record<string, GateDecision> = {}; for (const [batchKey, check] of Object.entries(checks)) { const [ability, subject] = check as unknown as readonly [ string, unknown?, ]; decisions[batchKey] = await evaluate( ctx, ability, subject, source, batchKey, ); } return decisions as PolicyBatchDecisionMap<TBatch>; } function bind(ctx: TContext): BoundGate<TPolicies> { return { can: async (ability, ...subject) => gate.can(ctx, ability, ...subject), canMany: async (checks) => gate.canMany(ctx, checks), inspect: async (ability, ...subject) => gate.inspect(ctx, ability, ...subject), inspectMany: async (checks) => gate.inspectMany(ctx, checks), authorize: async (ability, ...subject) => gate.authorize(ctx, ability, ...subject), }; } function attach<C extends TContext & object>( ctx: C, ): C & GateContext<TPolicies> { Object.defineProperty(ctx, "gate", { configurable: true, enumerable: false, get(this: TContext) { return bind(this); }, }); return ctx as C & GateContext<TPolicies>; } const gate: GatePort<TContext, TPolicies> = { bind, attach, async can(ctx, ability, ...subject) { return (await evaluate(ctx, ability, firstSubject(subject), "can")) .allowed; }, async canMany(ctx, checks) { const decisions = await inspectMany(ctx, checks, "canMany"); const result: Record<string, boolean> = {}; for (const [key, decision] of Object.entries(decisions)) { result[key] = decision.allowed; } return result as PolicyBatchBooleanMap<typeof checks>; }, inspect: async (ctx, ability, ...subject) => evaluate(ctx, ability, firstSubject(subject), "inspect"), inspectMany: async (ctx, checks) => inspectMany(ctx, checks, "inspectMany"), authorize: async (ctx, ability, ...subject) => authorize(ctx, ability, firstSubject(subject)), }; return gate; } function normalizeDecision(result: GatePolicyResult): GateDecision { if (typeof result === "boolean") { return result ? allow() : deny(); } return result; } function firstSubject(subject: readonly unknown[]): unknown { return subject[0]; } function policyNow(): number { return typeof performance !== "undefined" ? performance.now() : Date.now(); } function contextStringField(ctx: unknown, field: string): string | undefined { if (!ctx || typeof ctx !== "object") return undefined; const value = (ctx as Record<string, unknown>)[field]; return typeof value === "string" ? value : undefined; } function enrichObservation<TContext>( observation: GateDecisionObservation<TContext>, ): GateDecisionObservation<TContext> { return { ...observation, requestId: contextStringField(observation.ctx, "requestId"), traceId: contextStringField(observation.ctx, "traceId"), spanId: contextStringField(observation.ctx, "spanId"), parentSpanId: contextStringField(observation.ctx, "parentSpanId"), traceparent: contextStringField(observation.ctx, "traceparent"), }; } function isPromiseLike(value: unknown): value is PromiseLike<unknown> { return ( value !== null && (typeof value === "object" || typeof value === "function") && typeof (value as { then?: unknown }).then === "function" ); } function createDecisionObserver<TContext>( observer: GateDecisionObserver<TContext> | undefined, ): (observation: GateDecisionObservation<TContext>) => void { return (observation) => { if (!observer) return; try { const result = observer(enrichObservation(observation)); if (isPromiseLike(result)) { Promise.resolve(result).catch(() => undefined); } } catch { // Observers are diagnostic only and must not affect authorization. } }; }