RBAC — roles & permissions
The permissions catalog, the dynamic Role table, the requirePermission middleware, and the OrSelf escape hatch.
Last updated May 4, 2026
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.
Granting roles: no privilege escalation
Three rules sit on top of requirePermission for the tenancy routes:
- The URL org must match the active org. Permissions are resolved from the
x-organization-idheader, so every/organizations/:organizationId/...route also mountsrequireOrgMatchesPath()(infrastructure/http/require-org-matches-path.ts). A mismatch is a403; without it a member of org A could act on org B by changing the path. - A role can only be handed out by someone who already holds it. Adding a member, inviting, and changing a member's role require the target role's permissions to be a subset of the caller's grants (a
*:*caller can grant anything, so only owners can create owners). Changing a member whose current role exceeds your grants is also refused, so an admin cannot demote an owner. - The last owner cannot be demoted or removed. The count-and-write runs inside one serializable transaction (
IMembershipRoleChanger/IMembershipRemover), so two concurrent demotions cannot both succeed. - Roles are bounded the same way. Creating or editing a role requires its permissions to be a subset of your grants (unless you hold
*:*); you cannot edit a role whose current permissions exceed yours, nor the role you currently hold (no self-widening). Removing a member whose role exceeds your grants (an admin removing an owner) is refused; removing yourself is always allowed.
Two more behaviours worth knowing: POST /organizations/:id/members requires users:create and DELETE .../members/:userId requires users:delete (removing yourself is always allowed), and deleting a role that any invitation still references returns 409 instead of failing on the foreign key.
audit:read
GET /api/v1/audit?organizationId=... requires the audit:read permission in that org (it exposes actor IPs and user agents), not just membership. It is an extra permission (like api-keys:manage), granted to *:* owners and to the default admin role; a migration adds it to existing admin system roles. Custom roles must be granted it explicitly. Reading your own entries (?actorUserId=<you>) needs no permission.
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.
/users is organization-scoped
GET /api/v1/users, GET|PATCH|DELETE /api/v1/users/:id only ever see members of the active organization (x-organization-id); a user outside it answers 404. Self-update (PATCH on your own id from a cookie session) stays org-agnostic. DELETE does not delete the account: users can belong to several organizations, so it removes the membership in the active organization (the last owner cannot be removed). Account deletion is the self-service /me/deletion flow.
API-key sessions carry their creator's id for audit only: they get 403 on /me/* and on platform-admin routes, even if the creator is a platform admin. requirePermissionOrSelf never applies the self shortcut to them either: an API key must hold the permission (e.g. users:update) even when targeting its creator's id, and the same holds for removing a member. Ownership transfer swaps owner and demoted role in one serializable transaction, so an org never ends up with 0 or 2 owners (a concurrent change answers 409).
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.