RBAC — roles & permissions
The permissions catalog, the dynamic Role table, the requirePermission middleware, and the OrSelf escape hatch.
Authorization is built around a closed catalog of resource:action strings, mapped to a role per organization, hydrated onto the request, then checked by middleware on each route.
The catalog
packages/shared/src/permissions/index.ts:
export const RESOURCES = [
'users',
'roles',
'settings',
'reports',
'organizations',
'billing',
'invitations',
'webhooks',
'api-keys',
'queues',
] as const;
export const ACTIONS = ['create', 'read', 'update', 'delete'] as const;
export type Permission = `${Resource}:${Action}` | '*:*';
export const SUPER_ADMIN: Permission = '*:*';The cross-product gives users:create, users:read, …, queues:delete — 40 permissions plus the *:* super-admin wildcard. A new resource automatically gains the four CRUD actions.
export const hasPermission = (
granted: ReadonlyArray<Permission>,
required: Permission,
): boolean => granted.includes(SUPER_ADMIN) || granted.includes(required);Dynamic roles
The tenancy module owns a Role Prisma model — per-organization roles with their own list of granted permissions. The seed creates three defaults per org:
- Owner —
*:* - Admin — everything except
roles:deleteandorganizations:delete(configurable in seed) - Member — read on most resources, no writes
Organizations can edit role grants at runtime through the roles endpoints (gated by roles:update). The previous static enum is gone — roles are data, not types.
How a request gets gated
authMiddleware → rejects 401 if no session
organizationContext → hydrates req.organization (active org + this user's role + its permissions)
hydratePermissions → copies req.organization.permissions onto req.grants
requirePermission(perm) → 403s if perm not in req.grants (and not '*:*')hydratePermissions always runs (never blocks anonymous traffic); requirePermission is the gate. Both live in apps/server/src/infrastructure/http/require-permission.ts.
router.post(
'/organizations/:id/members',
authMiddleware,
organizationContext,
hydratePermissions,
writeLimiter,
requirePermission('invitations:create'),
validateRequest({ body: InviteSchema }),
handler,
);requirePermissionOrSelf
For routes where editing your own resource should be free but editing someone else's needs the grant:
import { requirePermissionOrSelf } from '@/infrastructure/http/require-permission.js';
router.patch(
'/users/:id',
authMiddleware,
organizationContext,
hydratePermissions,
writeLimiter,
requirePermissionOrSelf('users:update', (req) => req.params.id),
validateRequest({ body: UpdateUserSchema }),
handler,
);The second argument extracts the target user id from the request. If it equals req.session.userId, the gate skips the permission check. Otherwise it falls through to the standard check.
This is how the boilerplate models "you can edit your own profile, but you need users:update to edit anyone else's" without two routes or in-handler branching.
Adding a permission
- Extend
RESOURCES(or, very rarely,ACTIONS) inpackages/shared/src/permissions/index.ts. The catalog regenerates itself. - Decide which roles get the new permission and update the seed (
apps/server/scripts/seed.ts). - Run
bun run generate:api— the OpenAPI spec embeds the security scheme, so the typed client picks up the new permission string. - Use
requirePermission('newresource:read')on the route. - Existing organizations need a backfill: a migration (or an admin endpoint) that adds the new permission to whichever roles you decided.
Don't introduce one-off permission strings outside the resource:action shape — the type system will reject them, and the convention exists so that requirePermission autocompletes the entire catalog.
Frontend visibility
req.grants is exposed on the session via /auth/me (or whichever endpoint loads the session bootstrap). The dashboard can hide UI for actions the user can't perform:
const { permissions } = useSession();
{hasPermission(permissions, 'invitations:create') && (
<Button onClick={openInviteModal}>Invite member</Button>
)}Hiding UI is not a security boundary — the server still gates. UI hiding is a UX nicety, no more.
Roadmap
- Per-resource scoping (
projects:update:ownvsprojects:update:any) — tracked. - UI for editing role permission grants — partly shipped under
(dashboard)/settings/roles. - Audit logging of role mutations — already wired through the
auditmodule viarole.permissions_changedevents.
Two authorization axes
The boilerplate has two orthogonal authorization models layered on every request:
- Org-scoped roles (this page) —
OrganizationMember.roleId→Role.permissions. Gates everything inside a tenant. Per-org, hot-reloadable, declarative. User.platformAdmin— a single boolean on the user. Gates the cross-tenant admin console (/admin/*,/api/v1/platform/*) via therequirePlatformAdminmiddleware. Mutually exclusive from #1: a platform admin is not automatically a member of any tenant org, and a tenant owner is not automatically a platform admin.
A real-world request usually only checks one axis — most endpoints live under /api/v1/organizations/{slug}/* and check (1); the platform admin console lives under /api/v1/platform/* and checks (2). The two never combine into a "super-org-admin" role: that would invite confusion about which gate to write where. See Platform admin for the second axis in detail.