API

Checking permissions

Guard a procedure with a feature key, seed the key in a migration, and check it in the UI.

The model is in Roles and permissions. This page is the how-to.

Guard the procedure

create: requirePermission("notes.create")
  .input(/* ... */)
  .mutation(/* ... */),

requirePermission fails closed. The call is refused if the feature doesn't exist, is switched off, or isn't granted to the caller's role.

For a check inside a procedure — for example, when the required permission depends on the input — call hasPermission directly:

import { hasPermission } from "../../permissions.js";

if (!(await hasPermission(ctx.session.user.role, "admin.users.update-owner"))) {
  throw new TRPCError({ code: "FORBIDDEN" });
}

Seed the feature

A feature key only exists once it's a row in features, with grants in feature_roles. Add both in a data-only migration:

cd backend && yarn drizzle-kit generate --custom --name notes_rbac_seed

Then write the rows into the file it creates:

INSERT INTO "features" ("key", "label", "enabled") VALUES
  ('notes.create', 'Notes > Create', true);

INSERT INTO "feature_roles" ("feature_key", "role", "granted") VALUES
  ('notes.create', 'owner', true),
  ('notes.create', 'administrator', true);

The new feature shows up in Admin → Feature Flags and Admin → Permissions.

Check it in the UI

Every session carries enabledFeatures. Use hasFeature to show or hide things:

import { hasFeature } from "./AuthHelpers/permissions";

const { data: session } = authClient.useSession();
const canCreate = hasFeature(session?.enabledFeatures, "notes.create");

Hiding a button isn't security. The server check is what counts; the UI check stops people clicking something that will fail.

Naming

KindPatternExample
A pagepage.<area>.<page>page.admin.users
A view<area>.<thing>.viewadmin.users.view
An action<area>.<thing>.<verb>admin.users.update-role

Organization permissions

Organization features work the same way, scoped to one organization. Build on requireOrganizationPermission, and take a top-level organizationId in the input:

update: requireOrganizationPermission("members.update")
  .input(z.object({ organizationId: z.string(), /* ... */ }))
  .mutation(/* ... */),

An organization feature only works when a global feature with the same key is also switched on. Seed both.

The checklist

Every new or changed feature answers four questions as part of the work, not later:

  1. Does it need its own feature flag — a features row?
  2. Which roles can view it?
  3. Which roles can change it?
  4. Does it need a new page — a page.* key — or does it live inside an existing one?