Skip to Content
RolesOption B — Dynamic Roles with Permissions

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

IdentifierLabel
blog.readView Blog Posts
blog.writeCreate / Edit Blog Posts
blog.deleteDelete Blog Posts
blog.approveApprove Blog Posts
social.readView Social Posts
social.writeCreate / Edit Social Posts
social.deleteDelete Social Posts
social.publishPublish Social Posts
campaign.readView Campaigns
campaign.writeCreate / Edit Campaigns
campaign.deleteDelete Campaigns
calendar.readView Content Calendar
content_brief.readView Content Briefs
content_brief.writeCreate / Edit Content Briefs
newsletter.readView Newsletters
newsletter.writeCreate / Edit Newsletters
newsletter.sendSend Newsletters
report.readView Reports
report.generateGenerate Reports
report.deleteDelete Reports

Strategy & Context

IdentifierLabel
context.readView Client Context
context.approveApprove Client Context
strategy.readView Strategy
strategy.approveApprove Strategy
goal.readView Goals
goal.writeEdit Goals

SEO & AI

IdentifierLabel
keyword.readView Keywords
keyword.writeManage Keywords
backlink.readView Backlinks
backlink.writeManage Backlinks
ai_visibility.readView AI Visibility
ai_search.readView AI Search
insights.readView Channel Insights
insights.generateGenerate Insights

Channels & Settings

IdentifierLabel
channel.readView Channels
channel.writeConnect / Disconnect Channels
settings.readView Settings
settings.writeEdit Settings
member.readView Team Members
member.inviteInvite Team Members
member.removeRemove Team Members

Admin (Platform-level only)

IdentifierLabel
admin.tenantsManage Tenants
admin.usersManage Users
admin.rolesManage Roles
admin.billingManage Billing
admin.agentsManage Agents
admin.auditView 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:

MethodPathDescription
GET/admin/v1/permissionsList all permissions (grouped by resource)
GET/admin/v1/roles/:roleId/permissionsGet permissions for a role
PUT/admin/v1/roles/:roleId/permissionsReplace full permission set for a role
POST/admin/v1/roles/:roleId/permissions/:permissionIdAdd single permission to role
DELETE/admin/v1/roles/:roleId/permissions/:permissionIdRemove 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:

  1. Load the user’s Role (cached per request)
  2. Load the role’s permissions (cached, invalidated on role update)
  3. Check if the required permission identifier is in the set

Files requiring migration (non-exhaustive):

FileCurrent check
apps/api/src/routers/admin/users.tsrole !== "super_admin"
apps/api/src/routers/admin/tenants.tsrole !== "super_admin"
apps/api/src/routers/tenant/main.tsmember.role string checks
apps/api/src/routers/dm/*.tsmember.role string checks
apps/dashboard/src/app/**/actions.tsrequireAuth() role checks
All portal middlewarerequireSuperAdmin() 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

  1. Can tenant admins create custom roles scoped to their tenant only?
  2. Should we support permission inheritance (role A extends role B)?
  3. Do we want a “permission denied” audit log entry, or only successful action logs?

© 2026 Leadmetrics — Internal use only