RBAC Internals
Role-based access control — permission model, role definitions, and middleware.
Toast uses a role-based access control (RBAC) system built on Better Auth's access control plugin. This page explains how permissions are defined, how roles map to permissions, and how the API enforces authorization.
For the design rationale, see ADR-013: Access Model.
Permission Model
The RBAC system has three layers:
- Statements — define what actions exist per resource
- Roles — bundle statements into named presets
- Middleware — checks the user's role against required permissions at request time
Statements
Permission statements are defined in shared/contracts/src/permissions.ts:
export const statements = {
...defaultStatements, // Better Auth's built-in (user mgmt, sessions)
content: ['create', 'edit_own', 'edit_all', 'publish', 'delete'],
members: ['view', 'manage'],
site: ['settings', 'billing', 'delete'],
} as const;Each key is a resource, and the array values are the actions available on that resource. Better Auth's defaultStatements add user management and session management actions.
Access Controller
The access controller is created from the statements and shared between server and client:
export const ac = createAccessControl(statements);This instance is passed to Better Auth's admin plugin on the server and the adminClient plugin on the client, ensuring permission checks are consistent on both sides.
Roles
Four roles are defined, each bundling a subset of permissions:
Admin
Full access to everything — all Better Auth admin permissions plus all Toast-specific permissions:
export const adminRole = ac.newRole({
...adminAc.statements,
content: ['create', 'edit_own', 'edit_all', 'publish', 'delete'],
members: ['view', 'manage'],
site: ['settings', 'billing', 'delete'],
});Editor
Can manage all content but not site settings or users:
export const editorRole = ac.newRole({
content: ['create', 'edit_own', 'edit_all', 'publish', 'delete'],
members: ['view'],
});Author
Can create and edit their own content only:
export const authorRole = ac.newRole({
content: ['create', 'edit_own'],
});Member
Least-privileged role with no permissions. Assigned to new users by default. Can authenticate but cannot perform any actions until an admin upgrades them:
export const memberRole = ac.newRole({});Role Summary
| Role | Content | Members | Site |
|---|---|---|---|
| Admin | create, edit_own, edit_all, publish, delete | view, manage | settings, billing, delete |
| Editor | create, edit_own, edit_all, publish, delete | view | — |
| Author | create, edit_own | — | — |
| Member | — | — | — |
Authorization Middleware
The API enforces permissions through two middleware functions:
requireAuth
Binary check — is the user authenticated? Returns 401 if no valid session exists.
requirePermission
Granular check — does the user's role include the required permissions? Located in apps/api/src/middleware/require-permission.ts.
// Require content creation permission
app.post('/api/content', requirePermission({ content: ['create'] }), handler);
// Require site settings permission
app.put('/api/settings', requirePermission({ site: ['settings'] }), handler);How It Works
- Session check — verifies the user is authenticated and has a
siteId(returns 401 if not) - Role lookup — reads the user's role from the session context
- Permission resolution — calls the role's
authorize()method with the required permissions - Decision — returns 403 if the role's statements don't include the required permissions, otherwise continues to the route handler
export function requirePermission(permissions: PermissionRequirement): MiddlewareHandler<ApiEnv> {
return async (c, next) => {
const user = c.get('user');
const siteId = c.get('siteId');
if (!user || !siteId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const role = roles[user.role];
const result = role.authorize(permissions);
if (!result.success) {
return c.json({ error: 'Forbidden', message: result.error }, 403);
}
return await next();
};
}Permission checks are resolved locally using the role presets — no additional database query is needed beyond the session that's already been extracted by the session middleware.
Adding New Permissions
To add a new resource or action:
-
Add the statement in
shared/contracts/src/permissions.ts:export const statements = { ...defaultStatements, content: ['create', 'edit_own', 'edit_all', 'publish', 'delete'], members: ['view', 'manage'], site: ['settings', 'billing', 'delete'], newResource: ['read', 'write'], // Add here } as const; -
Update roles to include the new statement where appropriate:
export const adminRole = ac.newRole({ ...adminAc.statements, // ... existing permissions newResource: ['read', 'write'], }); -
Use in routes with
requirePermission:app.get('/api/new-resource', requirePermission({ newResource: ['read'] }), handler);
Because the contracts package is shared, both the API and admin panel see the updated permissions immediately after rebuilding.
Client-Side Permission Checks
The admin panel uses the same permission definitions from shared/contracts to conditionally render UI elements:
- Buttons are hidden or disabled when the user lacks the required permission
- Routes redirect to a "forbidden" page when accessed without permission
- The permission check uses Better Auth's
adminClientplugin with the sameacinstance
This ensures the UI and API always agree on what a user can and cannot do.