UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

232 lines 7.74 kB
/** * Default error thrown by `authorize(...)` when a policy denies access. */ export class GateAuthorizationError extends Error { code; status = 403; details; constructor(decision = 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() { 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) { 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(policies) { 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(options) { const registry = new Map(); 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, ability, subject, source = "inspect", batchKey) { 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, subject); 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, ability, subject) { 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(ctx, checks, source) { const decisions = {}; for (const [batchKey, check] of Object.entries(checks)) { const [ability, subject] = check; decisions[batchKey] = await evaluate(ctx, ability, subject, source, batchKey); } return decisions; } function bind(ctx) { 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(ctx) { Object.defineProperty(ctx, "gate", { configurable: true, enumerable: false, get() { return bind(this); }, }); return ctx; } const gate = { 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 = {}; for (const [key, decision] of Object.entries(decisions)) { result[key] = decision.allowed; } return result; }, 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) { if (typeof result === "boolean") { return result ? allow() : deny(); } return result; } function firstSubject(subject) { return subject[0]; } function policyNow() { return typeof performance !== "undefined" ? performance.now() : Date.now(); } function contextStringField(ctx, field) { if (!ctx || typeof ctx !== "object") return undefined; const value = ctx[field]; return typeof value === "string" ? value : undefined; } function enrichObservation(observation) { 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) { return (value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function"); } function createDecisionObserver(observer) { 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. } }; } //# sourceMappingURL=policy.js.map