Authorization
Authorization answers "may this actor do this action to this resource?" Beignet gives apps a small Gate and Policy convention so rules are typed, testable, and reusable from HTTP handlers, jobs, scripts, and tests.
Authentication still answers "who is this?" Keep that at the request boundary. Authorization usually needs domain data, so run policy checks inside use cases after loading the resource. Tenant scoping should also happen in repositories where possible, but policies are still the place that proves a loaded record belongs to the current actor and tenant.
The model
| Check | Put it here | Reason |
|---|---|---|
| Is a session present? | Auth hook or requireUser(ctx) from @beignet/core/ports | It is an identity concern. |
| Is a route public or protected? | Contract metadata plus hooks | It is transport-level policy. |
| Can this user update this resource? | Use case via ctx.gate.authorize(...) | It needs domain data. |
| Can this tenant access this record? | Repository filter plus policy check | Filtering prevents leaks; policy checks protect direct lookups. |
| Should a job perform the action? | Job handler or shared use case | Jobs do not pass through HTTP hooks. |
Define policies
Policies are plain TypeScript modules created with definePolicy(...). Keep
them feature-owned when they protect feature-owned resources:
import {
allow,
definePolicy,
deny,
type ActivityActor,
type ActivityTenant,
} from "@beignet/core/ports";
import type { Post } from "@/features/posts/ports";
export type AuthorizationContext = {
actor: ActivityActor;
tenant?: ActivityTenant;
};
function sameTenant(ctx: AuthorizationContext, post: Post) {
if (ctx.tenant?.id === post.tenantId) return allow();
return deny({
reason: "Post belongs to another tenant.",
code: "TENANT_MISMATCH",
});
}
export const postPolicy = definePolicy({
"posts.update": (ctx: AuthorizationContext, post: Post) => {
const tenant = sameTenant(ctx, post);
if (!tenant.allowed) return tenant;
if (ctx.actor.type === "user" && ctx.actor.id === post.authorId) {
return true;
}
return deny("Only the post author can update this post.");
},
"posts.publish": (ctx: AuthorizationContext, post: Post) => {
const tenant = sameTenant(ctx, post);
if (!tenant.allowed) return tenant;
if (ctx.actor.type === "user" && ctx.actor.id === "admin") return true;
return deny("Only admins can publish posts.");
},
});Return true or allow() to permit the action. Return false or deny(...)
to block it. Use deny(...) when you want a reason for logs, devtools, tests,
or the response message.
Subject-based ownership policies
Most record-scoped rules are ownership checks: the policy receives the loaded resource as its subject and compares it against the current identity. Keep the check in the policy — not inline in the use case — so the rule is testable in a matrix and reusable from jobs and scripts:
import { type ActivityActor, definePolicy, deny } from "@beignet/core/ports";
import type { Tweet } from "@/features/tweets/ports";
import type { AuthSession } from "@/ports/auth";
export type AuthorizationContext = {
actor: ActivityActor;
auth: AuthSession | null;
};
export const tweetPolicy = definePolicy({
"tweets.delete": (ctx: AuthorizationContext, tweet: Tweet) => {
if (ctx.actor.type !== "user") {
return deny("You must be signed in to delete tweets.");
}
if (tweet.authorId !== ctx.actor.id) {
return deny({
reason: "Only the author can delete this tweet.",
code: "NOT_TWEET_AUTHOR",
details: { tweetId: tweet.id, authorId: tweet.authorId },
});
}
return true;
},
});The use case loads the tweet first, then authorizes with the loaded record as the subject:
const tweet = await ctx.ports.tweets.get(input.id);
if (!tweet) {
throw appError("TweetNotFound", { details: { id: input.id } });
}
await ctx.gate.authorize("tweets.delete", tweet);A deny(...) with a code keeps the denial identity stable for tests,
devtools, and onDeny error mapping even when the human-readable reason copy
changes.
Where an ability lives
An ability lives in the policy of the feature that owns the authorized
resource — the subject the rule inspects — not the feature that performs the
action. For example, comments.create authorizes against an Issue (may this
actor comment on this issue?), so it belongs in the issues feature's policy
even though the comments feature performs the write:
// features/issues/policy.ts
export const issuePolicy = definePolicy({
// ...
"comments.create": (ctx: AuthorizationContext, issue: Issue) => {
const tenantDecision = canAccessTenant(ctx, issue);
if (!tenantDecision.allowed) return tenantDecision;
return isAuthenticated(ctx) || deny("You must be signed in.");
},
});This keeps every rule about a resource in one module, so reviewing "who can touch an issue" means reading one policy instead of grepping every feature that interacts with issues.
Create a gate
Register policies once in infra:
import { createGate } from "@beignet/core/ports";
import { appError } from "@/features/shared/errors";
import { postPolicy } from "@/features/posts/policy";
export const gate = createGate({
policies: [postPolicy],
onDeny(decision) {
return appError("Forbidden", {
message: decision.reason ?? "Forbidden",
});
},
});onDeny lets the app map policy failures to its own error catalog. If omitted,
authorize(...) throws Beignet's default GateAuthorizationError, which
the server maps to a standard framework-owned 403 response.
Install the gate as a port and declare it in the server/context.ts blueprint:
// infra/app-ports.ts
export const appPorts = definePorts({
gate,
// other ports...
});// server/context.ts
import { createAnonymousActor, createTenant, createUserActor } from "@beignet/core/ports";
import { defineServerContext } from "@beignet/core/server";
import type { AppContext } from "@/app-context";
export const appContext = defineServerContext<
AppContext,
AppContext["ports"]
>()({
gate: (ports) => ports.gate,
request: async ({ ports, req, requestId, trace }) => {
const tenantId = req.headers.get("x-tenant-id") || undefined;
const auth = await ports.auth.getSession({ headers: req.headers, raw: req });
return {
requestId,
...trace,
actor: auth
? createUserActor(auth.user.id, { displayName: auth.user.name })
: createAnonymousActor(),
auth,
ports,
tenant: tenantId ? createTenant(tenantId) : undefined,
};
},
});This keeps the policy registry in ctx.ports.gate and gives use cases the
context-bound ctx.gate surface, which always evaluates against the current
actor and tenant — including identity added later by auth hooks. The
practical rule: never hand-bind the gate (it is a compile error), and never
spread-copy the context to change identity — { ...ctx } drops the gate, so
use ctx.ports.gate.attach({ ...ctx, actor }) for a derived context instead.
Tenant context
Request context should carry the current actor and tenant. Authentication hooks or server context creation usually derive them from a session, signed token, subdomain, or trusted gateway header.
Repositories should accept tenant scope for list and lookup operations when the record belongs to a tenant:
const result = await ctx.ports.posts.findMany({
page,
tenantId: ctx.tenant?.id,
});Still authorize after loading a record. That catches direct lookups, jobs, scripts, and future code paths that might not share the same repository filter.
When a workflow cannot proceed without a tenant scope, require it with
requireTenantId(ctx) (or requireTenant(ctx)) from @beignet/core/ports.
The helpers throw TenantRequiredError, which the server maps to a
framework-owned 403 with code TENANT_REQUIRED:
import { requireTenantId } from "@beignet/core/ports";
const tenantId = requireTenantId(ctx);For sensitive records, prefer repository methods that require tenant scope:
const record = await ctx.ports.records.findById({
recordId: input.recordId,
tenantId,
});
if (!record) {
throw appError("RecordNotFound", {
details: { recordId: input.recordId },
});
}
await ctx.gate.authorize("records.view", record);Use policies
Load the resource first, then authorize the action:
const updatePost = useCase
.command("posts.update")
.input(UpdatePostInput)
.output(PostOutput)
.run(async ({ ctx, input }) => {
const post = await ctx.ports.posts.findById(input.id);
if (!post) {
throw appError("PostNotFound", { details: { id: input.id } });
}
await ctx.gate.authorize("posts.update", post);
return ctx.ports.posts.update(input.id, input);
});Use ctx.gate.can(...) when you need a boolean and ctx.gate.inspect(...)
when a UI, test, or devtools integration needs the full decision:
const canPublish = await ctx.gate.can("posts.publish", post);
const decision = await ctx.gate.inspect("posts.publish", post);Use canMany(...) or inspectMany(...) when a workflow needs a stable
permission map. Batch checks use object keys so API responses and UI code do not
depend on array order:
const permissions = await ctx.gate.canMany({
update: ["posts.update", post],
publish: ["posts.publish", post],
delete: ["posts.delete", post],
});
return {
post,
permissions,
};These maps are useful for read models that drive UI affordances. For example,
a detail query can return { post, permissions }, and the page can hide or
disable edit, publish, and delete actions from that permission map.
Treat permission maps as presentation hints only. Mutating use cases should
still load the resource and enforce the decision with authorize(...) before
performing the write.
Observe decisions
createGate(...) can observe policy decisions without changing authorization
behavior. The observer is best effort: thrown or rejected observer errors are
ignored, and onDeny still controls denied authorize(...) errors.
import { createGate } from "@beignet/core/ports";
import type { AppContext } from "@/app-context";
import { postPolicy } from "@/features/posts/policy";
const gate = createGate<AppContext, [typeof postPolicy]>({
policies: [postPolicy],
onDecision(event) {
event.ctx.ports.devtools?.record({
type: "custom",
watcher: "policies",
name: event.ability,
summary: event.decision?.allowed ? "allowed" : "denied",
requestId: event.requestId,
traceId: event.traceId,
details: {
ability: event.ability,
allowed: event.decision?.allowed,
code: event.decision?.allowed ? undefined : event.decision?.code,
reason: event.decision?.allowed ? undefined : event.decision?.reason,
source: event.source,
batchKey: event.batchKey,
durationMs: event.durationMs,
},
});
},
});Devtools includes a policies watcher and Policies view for these custom
events. When the event comes from canMany(...) or inspectMany(...), include
batchKey so the timeline can show which permission-map entry allowed or
denied each affordance.
Durable audit logging is still app-owned: record only the policy decisions your compliance model requires.
Test policies
Use @beignet/core/ports/testing for matrix tests that document tenant,
ownership, and role decisions without going through HTTP:
import { createPolicyTester } from "@beignet/core/ports/testing";
import { postPolicy } from "@/features/posts/policy";
const tester = createPolicyTester({ policies: [postPolicy] });
const sameTenantPost = {
id: "post_1",
tenantId: "tenant_1",
authorId: "alice",
status: "draft",
// ...remaining Post fields, built by a feature test factory
};
await tester.assertMatrix([
{
name: "author can update same tenant post",
ctx: {
actor: { type: "user", id: "alice" },
tenant: { id: "tenant_1" },
},
ability: "posts.update",
subject: sameTenantPost,
expected: "allow",
},
{
name: "admin cannot publish another tenant post",
ctx: {
actor: { type: "user", id: "admin" },
tenant: { id: "tenant_2" },
},
ability: "posts.publish",
subject: sameTenantPost,
expected: "deny",
code: "TENANT_MISMATCH",
},
]);Also test the use case so you prove the workflow enforces the policy:
await expect(
updatePost.run({
ctx: makeContext({ user: { id: "other_user" } }),
input: { id: "post_1", title: "New title" },
}),
).rejects.toMatchObject({
code: "FORBIDDEN",
});Error catalog
Expected authorization failures should be declared on route contracts when the app maps policy failures to app errors:
export const errors = defineErrors({
Unauthorized: httpErrors.Unauthorized,
Forbidden: httpErrors.Forbidden,
});
export const updatePost = posts
.put("/:id")
.errors({
Unauthorized: errors.Unauthorized,
Forbidden: errors.Forbidden,
PostNotFound: errors.PostNotFound,
});Clients can branch on stable error identity:
if (updatePostEndpoint.isError(error, { code: "FORBIDDEN" })) {
showAccessMessage();
}Privileged access
Impersonation and emergency access should be explicit application workflows, not hidden branches inside generic policies. Model them as separate abilities, capture the reason in input, and record durable audit events:
await ctx.gate.authorize("records.breakGlass", record);
await ctx.ports.audit.record(
auditEntry(ctx, {
action: "records.break-glass",
resource: { type: "record", id: record.id },
message: "Break-glass record access granted.",
metadata: {
reason: input.reason,
severity: "high",
},
}),
);The important rule is that dangerous access paths must be searchable later: actor, tenant, resource, reason, request id, and timestamp should all be present in the audit entry.
Generate a policy
The CLI can create a starter policy:
beignet make policy postsThat writes features/posts/policy.ts. Replace the starter abilities with your
domain rules, register the policy with createGate(...), and declare the gate
in the server/context.ts blueprint with gate: (ports) => ports.gate.