Roles and Permissions
Role-based access control for organizations using @kit/rbac and Better Auth.
The kit uses a configuration-driven role system powered by the @kit/rbac package and Better Auth's permission system.
Configuration
All role and permission settings are configured in a single file: packages/rbac/src/rbac.config.ts.
This file is the only file you need to edit for all RBAC customizations.
import { defineRBACConfig } from './core/factory';export default defineRBACConfig({ // Add custom resources resources: { PROJECT: 'project' }, // Add custom actions (optional) actions: { ARCHIVE: 'archive' }, // Add custom roles with hierarchy levels roles: { moderator: 30 }, // Define which actions are valid for each custom resource accessController: { project: ['create', 'read', 'update', 'delete', 'archive'], }, // Define permissions for each role on custom resources permissions: { owner: { project: ['create', 'read', 'update', 'delete', 'archive'] }, admin: { project: ['create', 'read', 'update'] }, member: { project: ['read'] }, },});Default resources (organization, member, invitation, billing, ac) and actions (create, read, update, delete, cancel) are automatically included.
Default Roles
Three roles are included by default:
| Role | Level | Description |
|---|---|---|
| owner | 100 | Full control over organization |
| admin | 50 | Can manage members and billing |
| member | 10 | Basic organization access |
Higher hierarchy levels grant more authority. A role can only manage roles with lower levels.
Typed Role Constants
The DefaultRole type provides autocomplete and type safety:
import { type DefaultRole } from '@kit/rbac';// DefaultRole = 'owner' | 'admin' | 'member'function setRole(role: DefaultRole) { // TypeScript provides autocomplete}// Also accepts custom roles when neededfunction setAnyRole(role: DefaultRole | string) { // Works with custom roles from Dynamic Access Control}Role Hierarchy Functions
The @kit/rbac package provides utilities for working with roles.
import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';// Admin can manage member (50 > 10)canTargetRole('admin', 'member', false, ROLE_HIERARCHY);// Returns: true// Admin cannot manage owner (50 < 100)canTargetRole('admin', 'owner', false, ROLE_HIERARCHY);// Returns: false// Equal roles cannot manage each other by defaultcanTargetRole('admin', 'admin', false, ROLE_HIERARCHY);// Returns: false// With allowEqual, same-level targeting is permittedcanTargetRole('admin', 'admin', true, ROLE_HIERARCHY);// Returns: trueClient-Side Role Checks
Use canTargetRole from @kit/rbac to check if a user can manage another member based on role hierarchy:
import { canTargetRole } from '@kit/rbac';export function MemberActions({ member, currentUserRole, roleHierarchy }) { const canManage = canTargetRole(currentUserRole, member.role, false, roleHierarchy); return ( <DropdownMenu> {canManage && ( <> <DropdownMenuItem onClick={() => changeRole(member.id)}> Change Role </DropdownMenuItem> <DropdownMenuItem onClick={() => removeMember(member.id)}> Remove Member </DropdownMenuItem> </> )} </DropdownMenu> );}The canTargetRole function checks if one role can manage another based on hierarchy levels:
import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';// Admin (50) can target member (10)canTargetRole('admin', 'member', false, ROLE_HIERARCHY); // true// Admin (50) cannot target owner (100)canTargetRole('admin', 'owner', false, ROLE_HIERARCHY); // false// With allowEqual=true, same-level targeting is permitted (useful for invitations)canTargetRole('admin', 'admin', true, ROLE_HIERARCHY); // trueServer-Side Permission Checks
Better Auth provides server-side authorization through the auth.api.hasPermission() method. On the server, request headers come from getRequestHeaders() (@tanstack/react-start/server). Wrap the check in a createServerFn so route loaders can call it during SSR and client-side navigation.
Check Permissions
import { createServerFn } from '@tanstack/react-start';import { getRequestHeaders } from '@tanstack/react-start/server';import { auth } from '@kit/better-auth';// Check if the current user can invite membersexport const fetchCanCreateInvitation = createServerFn({ method: 'GET' }).handler( async () => { const { success } = await auth.api.hasPermission({ headers: getRequestHeaders(), body: { permissions: { invitation: ['create'], }, }, }); return success; },);Check Multiple Permissions
import { getRequestHeaders } from '@tanstack/react-start/server';import { auth } from '@kit/better-auth';const { success: canAdminister } = await auth.api.hasPermission({ headers: getRequestHeaders(), body: { permissions: { member: ['create', 'update', 'delete'], invitation: ['create', 'update'], }, },});Protect Mutations
Mutations are server functions built from the action factories in @kit/action-middleware. Continue the createServerFn chain with .validator(schema) and .handler(...); the handler's context is typed by the middleware.
import { organizationAction, withFeaturePermission } from '@kit/action-middleware';import { createUpdateOrganizationService } from '@kit/organization-core/services';import { UpdateOrganizationSchema } from './update-organization.schema';export const updateOrganization = organizationAction() .middleware([withFeaturePermission({ organization: ['update'] })]) .validator(UpdateOrganizationSchema) .handler(async ({ data, context }) => { // context.organizationId is available; the user has organization:update const service = createUpdateOrganizationService(); await service.updateOrganization({ organizationId: context.organizationId, name: data.name, }); });Server Action Authorization Middleware
The action factories pre-bind authorization middleware to a createServerFn builder. Layer extra gates with the native .middleware([...]) step.
withFeaturePermission
Checks resource permissions via Better Auth:
import { authAction, withFeaturePermission } from '@kit/action-middleware';export const updateOrg = authAction() .middleware([withFeaturePermission({ organization: ['update'] })]) .validator(UpdateOrgSchema) .handler(async ({ data, context }) => { // context.user is set; the user has organization:update permission });withMinRole
Checks minimum role level using the hierarchy:
import { authAction, withMinRole } from '@kit/action-middleware';export const deleteOrg = authAction() .middleware([withMinRole('owner')]) .validator(DeleteOrgSchema) .handler(async ({ data, context }) => { // Only owners can reach here });organizationAction
Pre-configured factory with organization context (context.organizationId, context.role) and no extra permission checks:
import { organizationAction } from '@kit/action-middleware';export const listMembers = organizationAction() .validator(ListMembersSchema) .handler(async ({ data, context }) => { const { organizationId, role } = context; // List members for the organization });Conditional Rendering via the Route Loader
Permission checks run server-side in the route loader (calling a server function), and the component reads the result with Route.useLoaderData():
import { createFileRoute } from '@tanstack/react-router';import { fetchCanCreateInvitation } from '#/lib/auth/account.functions';export const Route = createFileRoute('/_authenticated/settings/members')({ loader: async () => { const canInvite = await fetchCanCreateInvitation(); return { canInvite }; }, component: MembersPage,});function MembersPage() { const { canInvite } = Route.useLoaderData(); return ( <div> <h1>Team Members</h1> {canInvite && <InviteMemberButton />} <MembersList /> </div> );}Default Permissions
Each role has predefined permissions:
| Permission | Owner | Admin | Member |
|---|---|---|---|
| Organization Read | Yes | Yes | Yes |
| Organization Update | Yes | Yes | No |
| Organization Delete | Yes | No | No |
| Member Create | Yes | Yes | No |
| Member Update | Yes | Yes | No |
| Member Delete | Yes | Yes | No |
| Invitation Manage | Yes | Yes | View only |
Customizing Roles
Adding Custom Roles
Add custom roles in packages/rbac/src/rbac.config.ts:
export default defineRBACConfig({ roles: { moderator: 30, // Between admin (50) and member (10) viewer: 5, // Below member },});Custom roles are merged with the default hierarchy (owner: 100, admin: 50, member: 10). They appear in invitation forms and role dropdowns automatically.
Better Auth Permission Templates
Better Auth maps roles to permission templates based on hierarchy level:
| Level | Template | Permissions |
|---|---|---|
| >= 100 | owner | Full organization control |
| >= 50 | admin | Manage members and billing |
| < 50 | member | Basic access |
Roles below level 50 (like moderator: 30 or viewer: 5) receive the member permission template. For finer control, add custom resources and actions.
Adding Resources and Actions
All customizations happen in packages/rbac/src/rbac.config.ts:
export default defineRBACConfig({ // Step 1: Add custom resources resources: { PROJECT: 'project', API_KEY: 'apiKey', }, // Step 2: Add custom actions (optional) actions: { EXPORT: 'export', IMPORT: 'import', }, // Step 3: Define which actions are valid for each custom resource accessController: { project: ['create', 'read', 'update', 'delete', 'export'], apiKey: ['create', 'read', 'delete'], }, // Step 4: Define permissions for each role on custom resources permissions: { owner: { project: ['create', 'read', 'update', 'delete', 'export'], apiKey: ['create', 'read', 'delete'], }, admin: { project: ['create', 'read', 'update', 'export'], apiKey: ['create', 'read'], }, member: { project: ['read'], apiKey: [], // No API key access }, },});Custom resources and actions are merged with the defaults (organization, member, invitation, billing, ac) and (create, read, update, delete, cancel).
Permission Check Patterns
Pattern 1: Resource-Based Checks
Check if the user can perform specific actions:
import { getRequestHeaders } from '@tanstack/react-start/server';import { auth } from '@kit/better-auth';const { success: canInvite } = await auth.api.hasPermission({ headers: getRequestHeaders(), body: { permissions: { invitation: ['create'] } },});const { success: canDelete } = await auth.api.hasPermission({ headers: getRequestHeaders(), body: { permissions: { organization: ['delete'] } },});Pattern 2: Role Hierarchy Checks
Check if a user can manage another member:
import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';const canManage = canTargetRole(currentUser.role, targetMember.role, false, ROLE_HIERARCHY);Import Reference
| Need | Import |
|---|---|
| Permission checks | @kit/better-auth (auth.api.hasPermission) |
| Role hierarchy functions | @kit/rbac (canTargetRole, getRoleHierarchy) |
| Resources and actions constants | @kit/rbac (RESOURCES, ACTIONS) |
| Role hierarchy | @kit/rbac (ROLE_HIERARCHY) |
| Config factory | @kit/rbac (defineRBACConfig) |
| Server action middleware | @kit/action-middleware (withFeaturePermission, withMinRole) |
| Authenticated action factory | @kit/action-middleware (authAction) |
| Organization action factory | @kit/action-middleware (organizationAction) |
Common Use Cases
Conditional UI Based on Permissions
Resolve the permission flags in the route loader, then read them in the component:
import { createFileRoute } from '@tanstack/react-router';import { fetchCanUpdateOrganization, fetchCanDeleteOrganization,} from '#/lib/auth/account.functions';export const Route = createFileRoute('/_authenticated/settings/organization')({ loader: async () => { const [canUpdate, canDelete] = await Promise.all([ fetchCanUpdateOrganization(), fetchCanDeleteOrganization(), ]); return { canUpdate, canDelete }; }, component: OrganizationSettings,});function OrganizationSettings() { const { canUpdate, canDelete } = Route.useLoaderData(); return ( <div> {canUpdate && <OrganizationSettingsForm />} {canDelete && <DangerZone />} </div> );}fetchCanUpdateOrganization ships in apps/web/src/lib/auth/account.functions.ts. Each helper is a createServerFn({ method: 'GET' }) wrapper around an auth.api.hasPermission(...) check; add your own (e.g. fetchCanDeleteOrganization) following the same pattern.
Filter Roles in Dropdowns
Filter which roles a user can assign based on their own role level:
import { getAllDefaultRoles, canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';export function RoleSelector({ currentUserRole }) { const allRoles = getAllDefaultRoles(); const availableRoles = allRoles.filter(role => canTargetRole(currentUserRole, role, true, ROLE_HIERARCHY) ); return ( <Select> {availableRoles.map(role => ( <SelectItem key={role} value={role}> {role} </SelectItem> ))} </Select> );}Validate Role Changes
Using middleware (recommended):
import { authAction, withFeaturePermission } from '@kit/action-middleware';import { requireActiveOrganizationId } from '@kit/better-auth/context';import { z } from 'zod';export const updateMemberRoleAction = authAction() .middleware([withFeaturePermission({ member: ['update'] })]) .validator(z.object({ memberId: z.string(), newRole: z.string() })) .handler(async ({ data, context }) => { const { memberId, newRole } = data; // context.user is set; the user already has member:update permission const organizationId = await requireActiveOrganizationId(); return auth.api.updateMemberRole({ body: { organizationId, memberId, role: newRole }, }); });This mirrors the real updateMemberRoleAction in packages/organization/ui/src/lib/server/actions/members-server-actions.ts.
Using a manual hierarchy check inside the handler:
import { authAction } from '@kit/action-middleware';import { getRequestHeaders } from '@tanstack/react-start/server';import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';import { auth } from '@kit/better-auth';export const updateMemberRoleAction = authAction() .validator(z.object({ memberId: z.string(), newRole: z.string() })) .handler(async ({ data }) => { const { role: currentRole } = await auth.api.getActiveMemberRole({ headers: getRequestHeaders(), }); if (!canTargetRole(currentRole, data.newRole, true, ROLE_HIERARCHY)) { throw new Error('Insufficient permissions'); } return auth.api.updateMemberRole({ body: { memberId: data.memberId, role: data.newRole }, }); });Related Documentation
- Permissions API Reference - Full API documentation
- Inviting Members - How role hierarchy affects invitations