Permissions System Skill
Three-layer permission architecture for role-based access control, feature gating, and usage limits.
Architecture Overview
THREE-LAYER PERMISSION SYSTEM:
Layer 1: Team Roles (RBAC)
├── Core roles: owner (100), admin (50), member (10), viewer (1)
├── Theme can extend: editor, contributor, moderator, etc.
└── Permissions defined in permissions.config.ts
Layer 2: Plans (Feature Gating)
├── Subscription-based feature access
├── Plan features defined in plans.config.ts
└── Checked via membership.hasFeature()
Layer 3: Quotas (Usage Limits)
├── Per-plan usage tracking
├── Enforced via membership.checkQuota()
└── Tracked in usage table
core/lib/permissions/
├── types.ts # Permission type definitions
├── check.ts # Server-side permission checks
├── hooks.ts # Frontend permission hooks
├── system.ts # Core system permissions
└── merge.ts # Configuration merging
core/lib/services/
├── permission.service.ts # O(1) permission lookups
└── membership.service.ts # Complete membership context
When to Use This Skill
- Implementing role-based access control
- Adding entity permissions
- Extending team roles in themes
- Checking permissions in API routes
- Rendering UI based on permissions
- Working with feature flags and quotas
User Roles vs Team Roles
CRITICAL: Understanding the Two Role Systems
This application uses two completely separate role systems for different purposes:
| Aspect | User Roles | Team Roles |
|---|---|---|
| Storage | user.role column |
teamMembers.role column |
| Scope | Global (entire app) | Per-team membership |
| Extensible | ❌ NO - Hardcoded in core | ✅ YES - Themes add custom roles |
| Purpose | Route access control | Entity permissions |
| Checked by | Middleware, roleHelpers | MembershipService |
User Roles (App-Level, FIXED - NOT Extensible)
// 3 fixed system roles - CANNOT be extended by themes
// Defined in: core/types/user.types.ts
type UserRole = 'member' | 'superadmin' | 'developer'
| Role | Hierarchy | Access | Routes |
|---|---|---|---|
member |
1 | Standard authenticated user | /dashboard/* |
superadmin |
99 | System admin, bypasses team permissions | /superadmin/* |
developer |
100 | Full access, debugging APIs | /devtools/* |
Why NOT extensible:
- Security: Route protection must be predictable
- Core functionality: Middleware relies on fixed set
- System integrity: Themes cannot grant system-level access
Check user role:
import { roleHelpers } from '@/core/lib/role-helpers'
// CORRECT: Use roleHelpers for user roles
if (roleHelpers.isDeveloper(user.role)) {
// Access to /devtools/*
}
if (roleHelpers.isSuperAdmin(user.role)) {
// Access to /superadmin/*, bypasses team checks
}
// WRONG: Never check user roles via membership
if (membership.hasRole('superadmin')) {} // This checks TEAM role, not user role!
Team Roles (Team-Level, EXTENSIBLE by Themes)
// Core team roles (protected, always available)
type CoreTeamRole = 'owner' | 'admin' | 'member' | 'viewer'
// Theme can add custom roles
type TeamRole = CoreTeamRole | 'editor' | 'contributor' | 'moderator' | string
| Role | Hierarchy | Description | Extensible |
|---|---|---|---|
owner |
100 | Team creator, all permissions, cannot be removed | ❌ Core |
admin |
50 | Team management, member roles, billing | ❌ Core |
member |
10 | Standard entity access | ❌ Core |
viewer |
1 | Read-only access | ❌ Core |
editor |
5 | Theme-defined: Edit without delete | ✅ Theme |
contributor |
3 | Theme-defined: Limited create/edit | ✅ Theme |
moderator |
7 | Theme-defined: Content moderation | ✅ Theme |
Complete Example: Adding Custom Team Roles
// contents/themes/your-theme/config/permissions.config.ts
import type { ThemePermissionsConfig } from '@/core/lib/permissions/types'
export const PERMISSIONS_CONFIG_OVERRIDES: ThemePermissionsConfig = {
// 1. Define custom team roles
roles: {
additionalRoles: ['editor', 'contributor', 'moderator'] as const,
hierarchy: {
editor: 5, // Between viewer (1) and member (10)
contributor: 3, // Above viewer, below editor
moderator: 7, // Above editor, below member
},
displayNames: {
editor: 'common.teamRoles.editor', // i18n key
contributor: 'common.teamRoles.contributor',
moderator: 'common.teamRoles.moderator',
},
descriptions: {
editor: 'Can view and edit content without delete access',
contributor: 'Can create and edit own content only',
moderator: 'Can moderate content and manage comments',
},
},
// 2. Define which roles can perform which actions (object format, not array)
entities: {
products: [
{ action: 'read', roles: ['owner', 'admin', 'member', 'editor', 'contributor', 'moderator', 'viewer'] },
{ action: 'create', roles: ['owner', 'admin', 'member', 'editor', 'contributor'] },
{ action: 'update', roles: ['owner', 'admin', 'member', 'editor'] },
{ action: 'delete', roles: ['owner', 'admin'] },
{ action: 'moderate', roles: ['owner', 'admin', 'moderator'] },
],
},
// 3. Define team-level permissions
teams: [
{ action: 'team.view', roles: ['owner', 'admin', 'member', 'editor', 'contributor', 'moderator', 'viewer'] },
{ action: 'team.edit', roles: ['owner', 'admin'] },
{ action: 'team.members.invite', roles: ['owner', 'admin'] },
{ action: 'team.members.remove', roles: ['owner', 'admin'] },
{ action: 'team.members.changeRole', roles: ['owner', 'admin'] },
{ action: 'team.delete', roles: ['owner'], dangerous: true },
],
}
Translations for Custom Roles
// contents/themes/your-theme/messages/en.json
{
"common": {
"teamRoles": {
"editor": "Editor",
"contributor": "Contributor",
"moderator": "Moderator"
}
}
}
// contents/themes/your-theme/messages/es.json
{
"common": {
"teamRoles": {
"editor": "Editor",
"contributor": "Colaborador",
"moderator": "Moderador"
}
}
}
IMPORTANT: Core roles (owner, admin, member, viewer) translations are in core/messages/. Theme MUST NOT redefine them.
Permission Format
// Pattern: "[scope].[action]"
type Permission = `${string}.${string}`
// Entity permissions
'customers.create'
'customers.read'
'customers.update'
'customers.delete'
// Team permissions
'team.view'
'team.edit'
'team.members.invite'
'team.members.remove'
'team.delete'
// Feature permissions
'page-builder.access'
'page-builder.custom-css'
'api-keys.manage'
Theme Extension Pattern
permissions.config.ts
// contents/themes/default/config/permissions.config.ts
import type { ThemePermissionsConfig } from '@/core/lib/permissions/types'
export const PERMISSIONS_CONFIG_OVERRIDES: ThemePermissionsConfig = {
// 1. Add custom team roles
roles: {
additionalRoles: ['editor'] as const,
hierarchy: { editor: 5 }, // Between viewer (1) and member (10)
displayNames: {
editor: 'common.teamRoles.editor', // i18n key
},
descriptions: {
editor: 'Can view and edit content with limited access',
},
},
// 2. Team-level permissions
teams: [
{ action: 'team.view', roles: ['owner', 'admin', 'member', 'viewer', 'editor'] },
{ action: 'team.edit', roles: ['owner', 'admin'] },
{ action: 'team.members.invite', roles: ['owner', 'admin'] },
{ action: 'team.members.remove', roles: ['owner', 'admin'] },
{ action: 'team.delete', roles: ['owner'], dangerous: true },
],
// 3. Entity permissions (entity.action format)
entities: {
customers: [
{ action: 'create', roles: ['owner', 'admin'], label: 'Create customers' },
{ action: 'read', roles: ['owner', 'admin', 'member', 'editor'] },
{ action: 'update', roles: ['owner', 'admin', 'member'] },
{ action: 'delete', roles: ['owner'], dangerous: true },
],
tasks: [
{ action: 'create', roles: ['owner', 'admin', 'member'] },
{ action: 'read', roles: ['owner', 'admin', 'member'] },
{ action: 'update', roles: ['owner', 'admin', 'member'] },
{ action: 'delete', roles: ['owner', 'admin'] },
// Note: 'editor' intentionally excluded from tasks
],
},
// 4. Feature permissions
features: [
{
action: 'page-builder.access',
roles: ['owner', 'admin', 'editor', 'member'],
label: 'Access Page Builder',
},
{
action: 'page-builder.custom-css',
roles: ['owner', 'admin'],
dangerous: true,
label: 'Use custom CSS',
},
],
// 5. Override or disable core permissions (optional)
// Example: extend core media permissions with theme-specific roles
overrides: {
'media.read': { roles: ['owner', 'admin', 'editor', 'member', 'viewer'] },
'media.upload': { roles: ['owner', 'admin', 'editor'] },
'media.update': { roles: ['owner', 'admin', 'editor'] },
},
disabled: [
// Disable specific core permissions
],
}
PermissionService
// core/lib/services/permission.service.ts
export class PermissionService {
// Check single permission - O(1)
static hasPermission(role: string, permission: Permission): boolean
// Check any action format (teams.*, entities.*, features.*)
static canDoAction(role: string, action: string): boolean
// Get all permissions for role - O(1)
static getRolePermissions(role: string): Permission[]
// Check multiple permissions
static hasAnyPermission(role: string, permissions: Permission[]): boolean
static hasAllPermissions(role: string, permissions: Permission[]): boolean
// Get permission configuration
static getConfig(permission: Permission): ResolvedPermission | undefined
// Full matrix for admin UI
static getMatrix(): { permissions, matrix, sections, roles }
}
Usage:
import { PermissionService } from '@/core/lib/services/permission.service'
// Check if admin can create customers
PermissionService.hasPermission('admin', 'customers.create') // true
// Check if editor can access tasks
PermissionService.canDoAction('editor', 'tasks.read') // false
MembershipService
// core/lib/services/membership.service.ts
// TeamMembership class - returned by MembershipService.get()
export class TeamMembership {
// Role checks
hasMinHierarchy(level: number): boolean
hasRole(role: string): boolean
hasAnyRole(roles: string[]): boolean
// Permission checks
hasPermission(permission: Permission): boolean
hasFeature(feature: string): boolean
// Quota checks
checkQuota(limitSlug: string, increment?: number): { allowed, remaining }
// Comprehensive action check (combines all layers)
canPerformAction(action: string, options?): ActionResult
}
export class MembershipService {
// Get complete membership context
static async get(userId: string, teamId: string): Promise<TeamMembership>
}
Usage:
import { MembershipService } from '@/core/lib/services/membership.service'
const membership = await MembershipService.get(userId, teamId)
// Layer 1: RBAC
if (membership.hasPermission('customers.delete')) {
// Can delete customers
}
// Layer 2: Plan features
if (membership.hasFeature('advanced_analytics')) {
// Show analytics dashboard
}
// Layer 3: Quotas
const quota = membership.checkQuota('projects', 1)
if (!quota.allowed) {
throw new Error('Project limit reached')
}
// Comprehensive check
const result = membership.canPerformAction('customers.create')
if (!result.allowed) {
// result.reason: 'permission_denied' | 'quota_exceeded' | 'feature_disabled'
throw new Error(result.message)
}
Server-Side Permission Checks
// core/lib/permissions/check.ts
// Single permission check
export async function checkPermission(
userId: string,
teamId: string,
permission: Permission
): Promise<boolean>
// Multiple permissions (AND - all required)
export async function checkPermissions(
userId: string,
teamId: string,
permissions: Permission[]
): Promise<boolean>
// Multiple permissions (OR - any sufficient)
export async function checkAnyPermission(
userId: string,
teamId: string,
permissions: Permission[]
): Promise<boolean>
// Synchronous check (when role is known)
export function hasPermissionSync(
teamRole: TeamRole,
permission: Permission
): boolean
API Route Usage:
import { checkPermission } from '@/core/lib/permissions/check'
import { createApiError } from '@/core/lib/api/response'
export async function DELETE(request: NextRequest, { params }) {
const { userId, teamId } = await getAuthContext(request)
// Check permission before action
const canDelete = await checkPermission(userId, teamId, 'customers.delete')
if (!canDelete) {
return createApiError('Permission denied', 403)
}
// Proceed with deletion
await CustomerService.delete(params.id, userId)
return createApiResponse({ success: true })
}
Frontend Permission Hooks
// core/lib/permissions/hooks.ts
'use client'
// Single permission check
export function usePermission(permission: Permission): boolean
// Multiple permissions (returns object)
export function usePermissions<T extends Record<string, Permission>>(
permissions: T
): Record<keyof T, boolean>
// Get all user permissions in team
export function useAllPermissions(): Permission[]
// Get current team role
export function useTeamRole(): TeamRole | null
Component Usage:
import { usePermission, usePermissions, useTeamRole } from '@/core/lib/permissions/hooks'
function CustomerActions({ customerId }) {
// Single permission
const canDelete = usePermission('customers.delete')
// Multiple permissions
const { canEdit, canExport } = usePermissions({
canEdit: 'customers.update',
canExport: 'customers.export',
})
// Role-based rendering
const role = useTeamRole()
return (
<>
{canEdit && <EditButton id={customerId} />}
{canDelete && <DeleteButton id={customerId} />}
{role === 'owner' && <OwnerOnlySettings />}
</>
)
}
Entity Permission Actions
// Standard entity actions
type EntityAction =
| 'create' // Create new record
| 'read' // View individual record
| 'list' // List records
| 'update' // Edit record
| 'delete' // Delete record
| 'export' // Export data
| 'import' // Import data
| 'assign' // Assign to user
| 'publish' // Publish record
| 'archive' // Archive record
// Permission action definition
interface EntityPermissionAction {
action: EntityAction | string
label: string
description?: string
roles: TeamRole[]
dangerous?: boolean
}
RLS Integration
Permissions work with Row Level Security at the database level:
// RLS policies use team membership
CREATE POLICY "users_can_view_own_team_data"
ON customers
FOR SELECT
USING (
"teamId" IN (
SELECT "teamId" FROM "teamMembers"
WHERE "userId" = current_setting('app.user_id')
)
);
Service layer sets RLS context:
import { queryWithRLS } from '@/core/lib/db'
// userId passed to set app.user_id for RLS
const customers = await queryWithRLS(
'SELECT * FROM customers WHERE status = $1',
['active'],
userId // Sets app.user_id
)
Build-Time Registry
// core/lib/registries/permissions-registry.ts (AUTO-GENERATED)
// Pre-computed at build time for O(1) lookups
export const ALL_PERMISSIONS: Permission[]
export const PERMISSIONS_BY_ROLE: Record<TeamRole, Set<Permission>>
export const PERMISSIONS_BY_CATEGORY: Record<string, ResolvedPermission[]>
export const TEAM_PERMISSIONS_BY_ROLE: Record<TeamRole, Permission[]>
Rebuild registry:
node core/scripts/build/registry.mjs
Anti-Patterns
// NEVER: Hardcode role checks
if (user.role === 'admin') { /* access */ }
// CORRECT: Use permission checks
if (await checkPermission(userId, teamId, 'customers.delete')) {}
// NEVER: Skip permission check on destructive actions
await CustomerService.delete(id, userId) // Missing permission check!
// CORRECT: Always check before destructive actions
const canDelete = await checkPermission(userId, teamId, 'customers.delete')
if (!canDelete) throw new Error('Permission denied')
await CustomerService.delete(id, userId)
// NEVER: Mix user roles and team roles
if (membership.hasRole('superadmin')) {} // Wrong context!
// CORRECT: Use appropriate role type
if (roleHelpers.isSuperAdmin(user.role)) {} // User role
if (membership.hasRole('admin')) {} // Team role
// NEVER: Check permissions client-side only
// Client checks are for UI, server MUST re-validate
// CORRECT: Always validate on server
// Client: usePermission() for UI
// Server: checkPermission() for action
Checklist
Before finalizing permission implementation:
- Uses three-layer check when appropriate (permission + feature + quota)
- Server-side checks on all protected routes
- Frontend hooks for conditional UI rendering
- Entity permissions defined in theme's permissions.config.ts
- Custom roles include hierarchy and display names
- Dangerous actions marked with
dangerous: true - RLS policies align with permission model
- Registry rebuilt after config changes
Related Skills
better-auth- Authentication patternsentity-api- API endpoint patternsservice-layer- Service patterns with RLS