launch-quickly
Patterns

Authorization

Who may do what — role permissions with can(), and resource-level rules (owner, assignee) in a slice's policy.ts. Use when a rule depends on the ROW and not just the role, or when tempted to compare ctx.userId in a component.

Generated from .claude/skills/authorization/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.

Two layers, and they answer different questions.

Role permissions answer "may this ROLE do this kind of thing":

can(ctx, 'tickets.update'); // in a page, to decide what to render
requirePermission(ctx, 'tickets.update'); // in a query, to enforce it

Declared in src/config/permissions.ts, granted per role, never compared as a role string (no-role-string-compare is a lint error).

Resource rules answer "may this PERSON do this to THIS ROW" — assignee, owner, author. Permissions cannot express that: the grant is the same for every member, and the answer depends on the record.

The pattern: policy.ts in the slice

// src/features/tickets/policy.ts
import 'server-only';
import { can, type TenantContext } from '@/lib/auth/context';

export function canResolve(
  ctx: TenantContext,
  ticket: Pick<Ticket, 'assigneeId'>,
): boolean {
  return can(ctx, 'tickets.resolve') || ticket.assigneeId === ctx.userId;
}

/** What this caller may do to this row — the page renders what it returns. */
export function allowedTransitionsFor(ctx: TenantContext, ticket: Ticket) {
  return TRANSITIONS[ticket.status].filter(
    (to) => !isRestricted(to) || canResolve(ctx, ticket),
  );
}

Three properties make this the sanctioned shape:

  1. One module per slice, server-only. The rule is written once and read by both the page (to decide what to render) and the action (to enforce it), so the UI can only ever narrow what the server already refuses.
  2. It builds on can(), it does not replace it. The role grant is still declared in permissions.ts; the policy adds the row-dependent exception.
  3. It returns DATA, not JSX decisions. allowedTransitionsFor gives the page a list; the page renders buttons from it. That is what keeps a component from re-deriving authorization.

Never compare ctx.userId outside policy.ts

ticket.assigneeId === ctx.userId scattered through components is the same failure as comparing role strings: correct today, silently wrong the day the rule gains a case, and impossible to grep for. Put it in the policy module and call the policy.

The action still enforces it

A policy consulted only by the page is decoration — hiding a button is convenience, not security. The mutation re-checks:

if (!canResolve(ctx, ticket))
  throw forbidden('Only the assignee or an admin can resolve this.');

FORBIDDEN reaching a user who typed a URL is a correct outcome, not a bug.

On this page