erify-authorization
erify_api Authorization Patterns
This skill provides erify_api-specific authorization implementation patterns, centered on current isSystemAdmin + StudioMembership behavior, with planned RBAC patterns kept as future-reference only.
Read this skill for current erify_api authorization behavior first. Load the planned-RBAC sections only when the task is explicitly about future authorization design.
Related references
- Authorization Guide
- Architecture Overview
authentication-authorization-nestjsfor broader auth guidancebackend-controller-pattern-nestjsfor controller/decorator usage
Implementation Status
[!IMPORTANT] Not all patterns in this skill are implemented. Check the status below before using a pattern.
| Pattern | Status | Notes |
|---|---|---|
isSystemAdmin bypass |
✅ Implemented | AdminGuard checks this flag only |
@AdminProtected() decorator |
✅ Implemented | Global guard in app.module.ts |
@StudioProtected([roles]) |
✅ Implemented | All 6 roles via StudioMembership (see role model below) |
StudioGuard with membership check |
✅ Implemented | Validates studio membership + role via getAllAndOverride (method > class) |
JSONB roles field on User |
⏳ Planned | Not in Prisma schema yet |
JSONB permissions field on User |
⏳ Planned | Not in Prisma schema yet |
ROLE_PERMISSIONS mapping |
⏳ Planned | AdminGuard does not expand roles |
Granular permission strings (module:action) |
⏳ Planned | Not implemented |
Studio Role Model
StudioMembership.role has 6 values. Use this table as the canonical access reference:
| Role | Scope | Can manage memberships |
|---|---|---|
ADMIN |
Full access — all studio features including membership management | ✅ Yes |
MANAGER |
Full access — all studio features except membership management | ❌ No |
TALENT_MANAGER |
Creator mapping only — catalog, roster, availability, show assignment | ❌ No |
DESIGNER |
Dashboard, own tasks, own shifts only | ❌ No |
MODERATION_MANAGER |
Dashboard, own tasks, own shifts only | ❌ No |
MEMBER |
Dashboard, own tasks, own shifts only | ❌ No |
Backend endpoint role conventions
// Read endpoints — all studio members (no explicit roles = member+)
@StudioProtected()
// Read/write endpoints for creator catalog/roster/availability and creator mapping ops
@StudioProtected([STUDIO_ROLE.ADMIN, STUDIO_ROLE.MANAGER, STUDIO_ROLE.TALENT_MANAGER])
// Write endpoints open to manager-level ops (tasks, shifts, shows/task context)
@StudioProtected([STUDIO_ROLE.ADMIN, STUDIO_ROLE.MANAGER])
// Admin-only (membership management, destructive ops)
@StudioProtected([STUDIO_ROLE.ADMIN])
getAllAndOverridemeans method-level@StudioProtectedalways wins over class-level. The class sets the default; methods narrow or expand as needed.
Core Principles
1. Separation of Concerns
Authentication (eridu_auth): Handles user identity and JWT issuance
Authorization (erify_api): Handles permissions and access control
IMPORTANT: Never add authorization claims to JWT payload. Keep JWTs minimal with identity claims only.
2. Multi-Scope Access
Different user types have different access scopes:
| User Type | Access Scope | Implementation |
|---|---|---|
| Creator | Own shows only | Via ShowMC relationship (DB internal) |
| Studio ADMIN | All studio features + membership management | Via StudioMembership role |
| Studio MANAGER | All studio features (no membership management) | Via StudioMembership role |
| Studio TALENT_MANAGER | Creator mapping, catalog, roster, availability | Via StudioMembership role |
| Studio DESIGNER / MODERATION_MANAGER | Own tasks and shifts only | Via StudioMembership role |
| Studio MEMBER | Own tasks and shifts only | Via StudioMembership role |
| Content Manager / System Manager | Planned RBAC only | Not implemented |
2.1 Workflow Action Authorization
For workflow actions (for example show resolution actions), authorization must be scope-specific and stricter than generic edit checks.
Minimum rule set:
- actor has required role in the target scope (for example studio admin),
- resource belongs to the scoped entity (for example show belongs to
:studioId), - cross-scope/system-only fallback is not assumed for normal studio operations.
3. Role-Based Permissions
Use roles for permission bundles, custom permissions for edge cases.
Permission Model
[!CAUTION] The following Permission Model section describes PLANNED (not yet implemented) patterns. The
rolesandpermissionsfields do NOT currently exist on theUsermodel. The currentAdminGuardonly checksisSystemAdmin. Do NOT use this code in production without first adding schema migrations.
Database Schema
model User {
isSystemAdmin Boolean @default(false) // Full access bypass
roles Json @default("[]") // ["content_manager", "analyst"]
permissions Json @default("[]") // ["users:read", "custom:feature"]
}
Storage: JSONB in PostgreSQL (Prisma Json type)
Why JSONB:
- Indexable with GIN for fast queries
- Type-safe (Prisma parses to
string[]) - Supports JSONB containment operators
Permission Format
Use module:action format:
users:read,users:writeshows:read,shows:writereports:read,reports:export
Role Definitions
Define roles in AdminGuard or shared constants:
const ROLE_PERMISSIONS: Record<string, string[]> = {
content_manager: ['shows:read', 'shows:write', 'schedules:read', 'schedules:write'],
analyst: ['users:read', 'shows:read', 'reports:read'],
support: ['users:read', 'tickets:read', 'tickets:write'],
system_manager: ['*:*'], // All permissions
};
Effective Permissions
Effective permissions = Role permissions + Custom permissions
Example:
{
"roles": ["content_manager"],
"permissions": ["reports:export"]
}
Effective: shows:read, shows:write, schedules:read, schedules:write, reports:export
Implementation Patterns
[!CAUTION] All code examples below (AdminGuard, Controller pattern, Frontend integration, Role assignment) are PLANNED patterns — they reference
user.roles,user.permissions, andROLE_PERMISSIONSwhich do NOT yet exist. See the status table above.
AdminGuard Pattern (Planned)
@Injectable()
export class AdminGuard implements CanActivate {
private readonly ROLE_PERMISSIONS: Record<string, string[]> = {
// Define role mappings here
};
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.getAllAndOverride<string[]>(
ADMIN_PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
) || [];
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = await this.userService.getUserByExtId(request.user.ext_id);
// 1. System admin bypasses all checks
if (user.isSystemAdmin) return true;
// 2. Expand roles to permissions
const userRoles = (user.roles as string[]) || [];
const rolePermissions = userRoles.flatMap(role => this.ROLE_PERMISSIONS[role] || []);
// 3. Combine with custom permissions
const customPermissions = (user.permissions as string[]) || [];
const effectivePermissions = [...new Set([...rolePermissions, ...customPermissions])];
// 4. Check if user has ALL required permissions
return requiredPermissions.every(req => effectivePermissions.includes(req));
}
}
Controller Pattern
@Controller('admin/users')
export class AdminUserController {
// Read-only access
@AdminProtected('users:read')
@Get()
getUsers() { ... }
// Write access
@AdminProtected('users:write')
@Post()
createUser() { ... }
// Multiple permissions required
@AdminProtected(['users:read', 'users:write'])
@Patch(':id')
updateUser() { ... }
// System admin only (no specific permission)
@AdminProtected()
@Delete(':id')
dangerousOperation() { ... }
}
Frontend Integration Pattern
Expose effective permissions via /me endpoint:
@Get()
async getMe(@CurrentUser() user: AuthenticatedUser) {
const dbUser = await this.userService.getUserByExtId(user.ext_id);
// Expand roles to effective permissions
const userRoles = (dbUser?.roles as string[]) || [];
const rolePermissions = userRoles.flatMap(role => ROLE_PERMISSIONS[role] || []);
const customPermissions = (dbUser?.permissions as string[]) || [];
const effectivePermissions = [...new Set([...rolePermissions, ...customPermissions])];
return {
...user,
isSystemAdmin: dbUser?.isSystemAdmin ?? false,
roles: userRoles,
permissions: effectivePermissions, // For UI permission checks
};
}
Best Practices
✅ DO
- Use roles for onboarding: Assign
roles: ["content_manager"]instead of 50 individual permissions - Use custom permissions for edge cases: Add specific permissions on top of roles
- Use granular permission strings:
users:read,users:write(notadmin:read) - Use isSystemAdmin for full access: Bypass all permission checks
- Keep permission logic in backend: Frontend uses same permission strings
- Document role definitions: Keep
ROLE_PERMISSIONSmapping well-documented - Use JSONB for storage: Enables fast queries with GIN indexes
❌ DON'T
- Don't add permissions to JWT: Keep JWTs minimal (identity only)
- Don't create roles for every edge case: Use custom permissions instead
- Don't use coarse permissions:
admin:readis too broad - Don't duplicate permission logic: Backend and frontend should use same strings
- Don't forget to expand roles: Always combine role + custom permissions
- Don't use TEXT/CSV for storage: JSONB is superior for queries
Common Patterns
Pattern 1: Read/Write Separation
// Read endpoints
@AdminProtected('module:read')
@Get()
list() { ... }
@AdminProtected('module:read')
@Get(':id')
get() { ... }
// Write endpoints
@AdminProtected('module:write')
@Post()
create() { ... }
@AdminProtected('module:write')
@Patch(':id')
update() { ... }
@AdminProtected('module:write')
@Delete(':id')
delete() { ... }
Pattern 2: Scoped Access
// Studio-scoped access
@Get('shows')
@AdminProtected('shows:read')
async getShows(@AuthUser() user) {
// Filter by user's studio memberships
const studioIds = user.studioMemberships.map(m => m.studioId);
return this.showService.findByStudioRooms(studioIds);
}
// Client-scoped access
@Get('shows')
@AdminProtected('shows:read')
async getShows(@AuthUser() user, @Query('clientId') clientId?: string) {
// Filter by user's client memberships or roles
const clientIds = this.getAccessibleClients(user);
return this.showService.findByClients(clientIds);
}
// System-wide access
@Get('shows')
@AdminProtected('shows:read:all')
async getAllShows() {
// No filtering - system manager only
return this.showService.findAll();
}
Pattern 3: Role Assignment
// Assign role to user
await prisma.user.update({
where: { id: userId },
data: { roles: ['content_manager'] },
});
// Add custom permission
await prisma.user.update({
where: { id: userId },
data: {
roles: ['analyst'],
permissions: ['reports:export'],
},
});
Troubleshooting
Permission Denied (403)
- Check user's
isSystemAdminflag - Check user's
rolesarray - Check user's
permissionsarray - Verify endpoint's
@AdminProtected()requirements - Check
AdminGuardlogs for missing permissions
Role Not Expanding
- Verify role name matches
ROLE_PERMISSIONSmapping - Check for typos in role name
- Ensure
ROLE_PERMISSIONSis defined consistently - Consider extracting to shared constants file
Permissions Not Updating
- Verify database update succeeded
- Check if caching is enabled (invalidate cache)
- Force token refresh (logout + login)
- Check
/meendpoint response
Related Skills
- Authentication Authorization NestJS - Comprehensive auth patterns
- Backend Controller Pattern NestJS - Controller patterns (admin, studio, me) with auth decorators
- Data Validation - Input validation and serialization
Related Documentation
- Authorization Guide (design-only; may be outdated vs current implementation)
- Architecture Overview