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
| Kind | Pattern | Example |
|---|---|---|
| A page | page.<area>.<page> | page.admin.users |
| A view | <area>.<thing>.view | admin.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:
- Does it need its own feature flag — a
featuresrow? - Which roles can view it?
- Which roles can change it?
- Does it need a new page — a
page.*key — or does it live inside an existing one?