@beignet/core
Version:
Core framework primitives for Beignet
688 lines (627 loc) • 19.3 kB
text/typescript
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[]> = {
[]: 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[]>,
> = {
[]: GateDecision;
};
/**
* Boolean decision map returned by `canMany(...)`.
*/
export type PolicyBatchBooleanMap<
TBatch extends PolicyBatch<readonly PolicyDefinition[]>,
> = {
[]: 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.
}
};
}