# Roles and Permissions

> Role-based access control for organizations using @kit/rbac and Better Auth.

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/members-management/roles-permissions*

---

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.

```typescript
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:

```typescript
import { type DefaultRole } from '@kit/rbac';

// DefaultRole = 'owner' | 'admin' | 'member'
function setRole(role: DefaultRole) {
  // TypeScript provides autocomplete
}

// Also accepts custom roles when needed
function 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.

```typescript
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 default
canTargetRole('admin', 'admin', false, ROLE_HIERARCHY);
// Returns: false

// With allowEqual, same-level targeting is permitted
canTargetRole('admin', 'admin', true, ROLE_HIERARCHY);
// Returns: true
```

## Client-Side Role Checks

Use `canTargetRole` from `@kit/rbac` to check if a user can manage another member based on role hierarchy:

```typescript
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:

```typescript
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); // true
```

## Server-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

```typescript
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 members
export const fetchCanCreateInvitation = createServerFn({ method: 'GET' }).handler(
  async () => {
    const { success } = await auth.api.hasPermission({
      headers: getRequestHeaders(),
      body: {
        permissions: {
          invitation: ['create'],
        },
      },
    });

    return success;
  },
);
```

### Check Multiple Permissions

```typescript
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 defined with a literal `createServerFn(...)` call
and the pre-composed middleware tuples from `@kit/function-middleware`. Continue the
chain with `.validator(schema)` and `.handler(...)`; the handler's `context` is
typed by the middleware.

```typescript
import { createServerFn } from '@tanstack/react-start';

import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';
import { withFeaturePermission } from '@kit/function-middleware/server';
import { createUpdateOrganizationService } from '@kit/organization-core/services';

import { UpdateOrganizationSchema } from './update-organization.schema';

export const updateOrganization = createServerFn({ method: 'POST' })
  .middleware([...organizationFunctionMiddleware, 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 Function Authorization Middleware

The pre-composed middleware tuples bind authorization middleware to a literal
`createServerFn(...)` call. Layer extra gates by spreading the tuple in the native
`.middleware([...])` step.

### withFeaturePermission

Checks resource permissions via Better Auth:

```typescript
import { createServerFn } from '@tanstack/react-start';

import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { withFeaturePermission } from '@kit/function-middleware/server';

export const updateOrg = createServerFn({ method: 'POST' })
  .middleware([...authFunctionMiddleware, 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:

```typescript
import { createServerFn } from '@tanstack/react-start';

import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { withMinRole } from '@kit/function-middleware/server';

export const deleteOrg = createServerFn({ method: 'POST' })
  .middleware([...authFunctionMiddleware, withMinRole('owner')])
  .validator(DeleteOrgSchema)
  .handler(async ({ data, context }) => {
    // Only owners can reach here
  });
```

### organizationFunctionMiddleware

Pre-composed middleware tuple that injects organization context
(`context.organizationId`, `context.role`) with no extra permission checks:

```typescript
import { createServerFn } from '@tanstack/react-start';

import { organizationFunctionMiddleware } from '@kit/function-middleware/functions';

export const listMembers = createServerFn({ method: 'POST' })
  .middleware(organizationFunctionMiddleware)
  .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()`:

```typescript
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`:

```typescript
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`:

```typescript
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:

```typescript
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:

```typescript
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 function middleware | `@kit/function-middleware` (withFeaturePermission, withMinRole) |
| Authenticated function middleware | `@kit/function-middleware` (authFunctionMiddleware) |
| Organization function middleware | `@kit/function-middleware` (organizationFunctionMiddleware) |

## Common Use Cases

### Conditional UI Based on Permissions

Resolve the permission flags in the route loader, then read them in the component:

```typescript
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:

```typescript
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):

```typescript
import { createServerFn } from '@tanstack/react-start';

import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { withFeaturePermission } from '@kit/function-middleware/server';
import { requireActiveOrganizationId } from '@kit/better-auth/context';
import { z } from 'zod';

export const updateMemberRoleFunction = createServerFn({ method: 'POST' })
  .middleware([...authFunctionMiddleware, 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 `updateMemberRoleFunction` in
`packages/organization/ui/src/lib/server/functions/members.functions.ts`.

Using a manual hierarchy check inside the handler:

```typescript
import { createServerFn } from '@tanstack/react-start';
import { getRequestHeaders } from '@tanstack/react-start/server';

import { authFunctionMiddleware } from '@kit/function-middleware/functions';
import { canTargetRole, ROLE_HIERARCHY } from '@kit/rbac';
import { auth } from '@kit/better-auth';

export const updateMemberRoleFunction = createServerFn({ method: 'POST' })
  .middleware(authFunctionMiddleware)
  .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](./permissions-api) - Full API documentation
- [Inviting Members](./invitations) - How role hierarchy affects invitations
