UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

480 lines (435 loc) 12.5 kB
/** * @beignet/core/entitlements * * Provider-neutral product access primitives for Beignet applications. */ type MaybePromise<T> = T | Promise<T>; /** * Stable app-defined capability key, such as "issues.create". */ export type EntitlementKey = string; /** * Principal whose product access is being checked. */ export type EntitlementSubject = { /** * App-defined subject type, commonly "tenant" or "account". */ type: string; /** * Stable subject identifier. */ id: string; }; /** * Input for checking whether a subject has access to a capability. */ export type EntitlementCheckInput< TKey extends EntitlementKey = EntitlementKey, > = { /** * Capability being checked. */ entitlement: TKey; /** * Tenant, account, or other app-owned subject whose product access is checked. */ subject: EntitlementSubject; }; /** * An entitlement decision that allows the capability. */ export type EntitlementAllowedDecision = { allowed: true; }; /** * An entitlement decision that denies the capability. */ export type EntitlementDeniedDecision = { allowed: false; reason?: string; code?: string; details?: unknown; }; /** * Normalized entitlement decision. */ export type EntitlementDecision = | EntitlementAllowedDecision | EntitlementDeniedDecision; /** * Value an entitlement resolver may return. */ export type EntitlementResolverResult = boolean | EntitlementDecision; /** * Entitlement port method that produced a decision. */ export type EntitlementDecisionSource = "can" | "inspect" | "require"; /** * Optional context for entitlement checks. */ export type EntitlementCheckOptions<TContext = unknown> = { /** * Application context used for the check. Observers can use this to copy * request correlation fields without coupling the resolver to app context. */ ctx?: TContext; /** * Source to report to observers. Port methods set this automatically. */ source?: EntitlementDecisionSource; }; /** * App-facing product access port. */ export type EntitlementsPort< TKey extends EntitlementKey = EntitlementKey, TContext = unknown, > = { /** * Return only whether the capability is allowed. */ can( input: EntitlementCheckInput<TKey>, options?: EntitlementCheckOptions<TContext>, ): Promise<boolean>; /** * Return the full allow/deny decision without throwing. */ inspect( input: EntitlementCheckInput<TKey>, options?: EntitlementCheckOptions<TContext>, ): Promise<EntitlementDecision>; }; /** * Function that decides whether an entitlement is granted. */ export type EntitlementResolver<TKey extends EntitlementKey = EntitlementKey> = ( input: EntitlementCheckInput<TKey>, ) => MaybePromise<EntitlementResolverResult>; /** * Best-effort entitlement decision observation emitted by an entitlements port. * * Observers are diagnostic only. They do not participate in entitlement * control flow and thrown/rejected observer errors are ignored. */ export type EntitlementDecisionObservation< TContext = unknown, TKey extends EntitlementKey = EntitlementKey, > = { /** * Port helper that produced the decision. */ source: EntitlementDecisionSource; /** * Application context passed by the caller, when available. */ ctx?: TContext; /** * Capability being evaluated. */ entitlement: TKey; /** * Subject whose product access is checked. */ subject: EntitlementSubject; /** * Normalized decision, when resolver evaluation returned normally. */ decision?: EntitlementDecision; /** * Error thrown by the resolver, when evaluation failed. */ error?: unknown; /** * Resolver 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 entitlement decision or resolver * error. */ export type EntitlementDecisionObserver< TContext = unknown, TKey extends EntitlementKey = EntitlementKey, > = ( observation: EntitlementDecisionObservation<TContext, TKey>, ) => MaybePromise<void>; /** * Options for `createEntitlements(...)`. */ export type CreateEntitlementsOptions< TKey extends EntitlementKey = EntitlementKey, TContext = unknown, > = { /** * Resolver that maps app-owned product state to an entitlement decision. */ inspect: EntitlementResolver<TKey>; /** * Optional best-effort observer for entitlement decisions. * * This hook is diagnostic only: it cannot change decisions or thrown errors. */ onDecision?: EntitlementDecisionObserver<TContext, TKey>; }; /** * Static grant map keyed by subject key. */ export type StaticEntitlementGrantMap< TKey extends EntitlementKey = EntitlementKey, > = Record<string, readonly TKey[]>; /** * Options for `createStaticEntitlements(...)`. */ export type CreateStaticEntitlementsOptions< TKey extends EntitlementKey = EntitlementKey, TContext = unknown, > = { /** * Granted capabilities keyed by subject. The default key is * `${subject.type}:${subject.id}`. */ grants: StaticEntitlementGrantMap<TKey>; /** * Optional subject key mapper. */ subjectKey?: (subject: EntitlementSubject) => string; /** * Default denial decision fields. */ denied?: Omit<EntitlementDeniedDecision, "allowed">; /** * Optional best-effort observer for entitlement decisions. */ onDecision?: EntitlementDecisionObserver<TContext, TKey>; }; /** * Options accepted by `requireEntitlement(...)`. */ export type RequireEntitlementOptions< TKey extends EntitlementKey = EntitlementKey, > = { /** * Create the error to throw instead of the framework default. */ error?: ( decision: EntitlementDeniedDecision, input: EntitlementCheckInput<TKey>, ) => unknown; }; /** * Context shape consumed by `requireEntitlement(...)`. */ export type EntitlementsContext<TKey extends EntitlementKey = EntitlementKey> = { ports: { entitlements: EntitlementsPort<TKey>; }; }; /** * Error thrown by `requireEntitlement(...)` when product access is denied. */ export class EntitlementRequiredError extends Error { readonly code: string; readonly status = 403; readonly details?: unknown; readonly entitlement: string; readonly subject: EntitlementSubject; constructor( input: EntitlementCheckInput, decision: EntitlementDeniedDecision = denyEntitlement(), ) { super( decision.reason ?? `Entitlement "${input.entitlement}" is required for this action.`, ); this.name = "EntitlementRequiredError"; this.code = decision.code ?? "ENTITLEMENT_REQUIRED"; this.details = decision.details; this.entitlement = input.entitlement; this.subject = input.subject; } } /** * Create an explicit allow decision. */ export function allowEntitlement(): EntitlementAllowedDecision { return { allowed: true }; } /** * Create an explicit deny decision. */ export function denyEntitlement( reasonOrDecision?: string | Omit<EntitlementDeniedDecision, "allowed">, ): EntitlementDeniedDecision { if (typeof reasonOrDecision === "string") { return { allowed: false, reason: reasonOrDecision }; } return { allowed: false, ...reasonOrDecision, }; } /** * Create an entitlement port from an app-owned resolver. */ export function createEntitlements< TKey extends EntitlementKey = EntitlementKey, TContext = unknown, >( options: CreateEntitlementsOptions<TKey, TContext>, ): EntitlementsPort<TKey, TContext> { const observeDecision = createDecisionObserver(options.onDecision); async function evaluate( input: EntitlementCheckInput<TKey>, checkOptions: EntitlementCheckOptions<TContext> | undefined, ): Promise<EntitlementDecision> { const startedAt = entitlementNow(); try { const decision = normalizeEntitlementDecision( await options.inspect(input), ); observeDecision({ source: checkOptions?.source ?? "inspect", ctx: checkOptions?.ctx, entitlement: input.entitlement, subject: input.subject, decision, durationMs: entitlementNow() - startedAt, }); return decision; } catch (error) { observeDecision({ source: checkOptions?.source ?? "inspect", ctx: checkOptions?.ctx, entitlement: input.entitlement, subject: input.subject, error, durationMs: entitlementNow() - startedAt, }); throw error; } } return { async can(input, checkOptions) { return (await evaluate(input, { ...checkOptions, source: "can" })) .allowed; }, inspect(input, checkOptions) { return evaluate(input, { ...checkOptions, source: checkOptions?.source ?? "inspect", }); }, }; } /** * Create a static entitlement port for tests, starters, and simple apps. */ export function createStaticEntitlements< TKey extends EntitlementKey = EntitlementKey, TContext = unknown, >( options: CreateStaticEntitlementsOptions<TKey, TContext>, ): EntitlementsPort<TKey, TContext> { const subjectKey = options.subjectKey ?? defaultSubjectKey; return createEntitlements<TKey, TContext>({ inspect(input) { const granted = options.grants[subjectKey(input.subject)] ?? []; if (granted.includes(input.entitlement)) return allowEntitlement(); return denyEntitlement({ reason: `Entitlement "${input.entitlement}" is not granted.`, code: "ENTITLEMENT_NOT_GRANTED", details: { entitlement: input.entitlement, subject: input.subject, }, ...options.denied, }); }, onDecision: options.onDecision, }); } /** * Require an entitlement or throw a framework-owned 403 error. */ export async function requireEntitlement< TKey extends EntitlementKey = EntitlementKey, >( ctx: EntitlementsContext<TKey>, input: EntitlementCheckInput<TKey>, options?: RequireEntitlementOptions<TKey>, ): Promise<EntitlementAllowedDecision> { const decision = await ctx.ports.entitlements.inspect(input, { ctx, source: "require", }); if (decision.allowed) return decision; throw options?.error ? options.error(decision, input) : new EntitlementRequiredError(input, decision); } function normalizeEntitlementDecision( result: EntitlementResolverResult, ): EntitlementDecision { if (typeof result === "boolean") { return result ? allowEntitlement() : denyEntitlement(); } return result; } function entitlementNow(): 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, TKey extends EntitlementKey>( observation: EntitlementDecisionObservation<TContext, TKey>, ): EntitlementDecisionObservation<TContext, TKey> { 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, TKey extends EntitlementKey>( observer: EntitlementDecisionObserver<TContext, TKey> | undefined, ): (observation: EntitlementDecisionObservation<TContext, TKey>) => 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 entitlement checks. } }; } function defaultSubjectKey(subject: EntitlementSubject): string { return `${subject.type}:${subject.id}`; }