@beignet/core
Version:
Core framework primitives for Beignet
232 lines • 7.74 kB
JavaScript
/**
* 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