Option B — Dynamic Roles with Permissions
Status: [To Build]
Effort: ~3–4 days
Risk: High — requires touching every auth guard and route across all portals
Prerequisite: Option A should ship first (or be merged into this as a base)
Overview
Full role + permission system. Superadmins can create custom roles, assign granular permissions (resource × action), and assign those roles to users. Built-in system roles are seeded and protected. All existing hardcoded role string checks across the API and portals must be migrated to permission checks.
Database Schema
model Role {
id String @id @default(cuid())
identifier String @unique
label String
description String @default("")
color String @default("gray")
level String // "platform" | "tenant"
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
permissions RolePermission[]
users User[] @relation("UserRole")
members TenantMember[] @relation("MemberRole")
}
model Permission {
id String @id @default(cuid())
identifier String @unique // e.g. "blog.write"
resource String // e.g. "blog"
action String // e.g. "write"
label String // e.g. "Write Blog Posts"
description String @default("")
isSystem Boolean @default(true)
createdAt DateTime @default(now())
roles RolePermission[]
}
model RolePermission {
roleId String
permissionId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
permission Permission @relation(fields: [permissionId], references: [id], onDelete: Cascade)
@@id([roleId, permissionId])
}User.role and TenantMember.role become foreign keys to Role.identifier (or Role.id). Migration required.
Permission Matrix (Draft)
All permissions follow resource.action format. This is the full set derived from existing API routes.
Content
| Identifier | Label |
|---|---|
blog.read | View Blog Posts |
blog.write | Create / Edit Blog Posts |
blog.delete | Delete Blog Posts |
blog.approve | Approve Blog Posts |
social.read | View Social Posts |
social.write | Create / Edit Social Posts |
social.delete | Delete Social Posts |
social.publish | Publish Social Posts |
campaign.read | View Campaigns |
campaign.write | Create / Edit Campaigns |
campaign.delete | Delete Campaigns |
calendar.read | View Content Calendar |
content_brief.read | View Content Briefs |
content_brief.write | Create / Edit Content Briefs |
newsletter.read | View Newsletters |
newsletter.write | Create / Edit Newsletters |
newsletter.send | Send Newsletters |
report.read | View Reports |
report.generate | Generate Reports |
report.delete | Delete Reports |
Strategy & Context
| Identifier | Label |
|---|---|
context.read | View Client Context |
context.approve | Approve Client Context |
strategy.read | View Strategy |
strategy.approve | Approve Strategy |
goal.read | View Goals |
goal.write | Edit Goals |
SEO & AI
| Identifier | Label |
|---|---|
keyword.read | View Keywords |
keyword.write | Manage Keywords |
backlink.read | View Backlinks |
backlink.write | Manage Backlinks |
ai_visibility.read | View AI Visibility |
ai_search.read | View AI Search |
insights.read | View Channel Insights |
insights.generate | Generate Insights |
Channels & Settings
| Identifier | Label |
|---|---|
channel.read | View Channels |
channel.write | Connect / Disconnect Channels |
settings.read | View Settings |
settings.write | Edit Settings |
member.read | View Team Members |
member.invite | Invite Team Members |
member.remove | Remove Team Members |
Admin (Platform-level only)
| Identifier | Label |
|---|---|
admin.tenants | Manage Tenants |
admin.users | Manage Users |
admin.roles | Manage Roles |
admin.billing | Manage Billing |
admin.agents | Manage Agents |
admin.audit | View Audit Logs |
System Role → Permission Mapping (Seed)
Platform: super_admin
All permissions.
Platform: admin
All non-admin.* permissions + admin.tenants, admin.users, admin.billing.
Platform: member
blog.read, social.read, campaign.read, calendar.read, goal.read, context.read, strategy.read, insights.read, ai_visibility.read, ai_search.read.
Platform: reviewer
Same as member + blog.approve, strategy.approve, context.approve.
Tenant: admin
All non-admin.* permissions.
Tenant: member
Read permissions + blog.write, social.write, campaign.write, content_brief.write, newsletter.write.
Tenant: reviewer
Same as tenant member + blog.approve.
API Routes
Extends Option A routes. Additional endpoints:
| Method | Path | Description |
|---|---|---|
GET | /admin/v1/permissions | List all permissions (grouped by resource) |
GET | /admin/v1/roles/:roleId/permissions | Get permissions for a role |
PUT | /admin/v1/roles/:roleId/permissions | Replace full permission set for a role |
POST | /admin/v1/roles/:roleId/permissions/:permissionId | Add single permission to role |
DELETE | /admin/v1/roles/:roleId/permissions/:permissionId | Remove single permission from role |
Screens
Extends Option A screens (RL1–RL3). Additional screen:
RL4 — Role Permissions Editor
Route: /roles/[roleId]/permissions
Access: Superadmin only
Layout:
- Role header (label + identifier + system badge)
- Permission matrix grouped by resource (accordion sections)
- Each resource section: resource name (h3) + permission rows
- Each permission row: checkbox + label + description
- “Select all” toggle per resource section
- Save button (sticky footer) — calls
PUT /admin/v1/roles/:roleId/permissions - Readonly mode for system roles with a banner: “System role permissions are managed by the platform. You can view but not edit them.”
Permission display order: Content → Strategy & Context → SEO & AI → Channels & Settings → Admin
Auth Check Migration
This is the highest-risk part of Option B. Every auth guard must change from:
// Before
if (user.role !== "super_admin") throw forbidden()
if (member.role === "reviewer") { ... }To a permission check helper:
// After
await requirePermission(user, "admin.tenants")
await requirePermission(member, "blog.approve")requirePermission must:
- Load the user’s Role (cached per request)
- Load the role’s permissions (cached, invalidated on role update)
- Check if the required permission identifier is in the set
Files requiring migration (non-exhaustive):
| File | Current check |
|---|---|
apps/api/src/routers/admin/users.ts | role !== "super_admin" |
apps/api/src/routers/admin/tenants.ts | role !== "super_admin" |
apps/api/src/routers/tenant/main.ts | member.role string checks |
apps/api/src/routers/dm/*.ts | member.role string checks |
apps/dashboard/src/app/**/actions.ts | requireAuth() role checks |
| All portal middleware | requireSuperAdmin() etc. |
Strategy: Migrate one router at a time. Keep requireSuperAdmin() as a shorthand that checks admin.users (or all permissions). Do not do a big-bang migration.
Caching
Role → permissions mapping should be cached in Redis (key: role:perms:{roleId}, TTL: 5 min). Invalidate on any RolePermission write. Without caching, every request hits the DB for permission lookup.
Open Questions
- Can tenant admins create custom roles scoped to their tenant only?
- Should we support permission inheritance (role A extends role B)?
- Do we want a “permission denied” audit log entry, or only successful action logs?