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

CheckPut it hereReason
Is a session present?Auth hook or app-owned requireUser(ctx) helperIt is an identity concern.
Is a route public or protected?Contract metadata plus hooksIt 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 checkFiltering prevents leaks; policy checks protect direct lookups.
Should a job perform the action?Job handler or shared use caseJobs 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.

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 bind it to the current request context:

export const appPorts = definePorts({
  gate,
  // other ports...
});

export const server = await createNextServer({
  ports: appPorts,
  createContext: async ({ ports, req }) => {
    const tenantId = req.headers.get("x-tenant-id") || undefined;
    const auth = await ports.auth.getSession({ headers: req.headers, raw: req });

    const context = {
      requestId: req.headers.get("x-request-id") ?? crypto.randomUUID(),
      actor: auth
        ? createUserActor(auth.user.id, { displayName: auth.user.name })
        : createAnonymousActor(),
      auth,
      ports,
      tenant: tenantId ? createTenant(tenantId) : undefined,
    };

    return {
      ...context,
      gate: ports.gate.bind(context),
    };
  },
});

This keeps the policy registry in ctx.ports.gate and gives use cases the request-bound ctx.gate surface.

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({
  tenantId: ctx.tenant?.id,
  limit: input.limit,
  offset: input.offset,
});

Still authorize after loading a record. That catches direct lookups, jobs, scripts, and future code paths that might not share the same repository filter.

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);

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";
import type { Post } from "@/features/posts/ports";

const tester = createPolicyTester({ policies: [postPolicy] });
const sameTenantPost = {
  id: "post_1",
  tenantId: "tenant_1",
  slug: "hello-world",
  title: "Hello world",
  content: "Post content",
  status: "draft",
  tags: [],
  authorId: "alice",
  authorName: "Alice",
  commentCount: 0,
  publishedAt: null,
  createdAt: "2026-05-25T12:00:00.000Z",
  updatedAt: "2026-05-25T12:00:00.000Z",
} satisfies Post;

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("patients.breakGlass", patient);

await ctx.ports.audit.record(
  auditEntry(ctx, {
    action: "patients.break-glass",
    resource: { type: "patient", id: patient.id },
    message: "Emergency chart access",
    metadata: {
      reason: input.reason,
      patientTenantId: patient.tenantId,
    },
  }),
);

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 posts

That writes features/posts/policy.ts. Replace the starter abilities with your domain rules, register the policy with createGate(...), and bind the gate in createContext.

Authentication vs authorization

Authentication belongs at the boundary and enriches context. Authorization belongs where the business decision is made. Keep both explicit: hooks identify the user, policies decide whether that user can perform a domain action.