Enforce 5-Role RBAC
What you'll learn
~30 min- Implement the 5-role hierarchy and permission matrix pattern
- Build the withPermission() higher-order function for API route protection
- Trace the 4-step role resolution chain from JWT to default assignment
Five roles, one enforcement pattern
The DS platform uses five roles with a strict hierarchy. Every API route, every server action, and every conditional UI element checks against this hierarchy. There is no ad-hoc permission checking — it all flows through withPermission().
| Role | Level | Description |
|---|---|---|
viewer | 1 | Read-only access. Dashboards and reports. Cannot modify data. |
operator | 2 | Day-to-day data entry. Can create and update records in assigned modules. |
manager | 3 | Module-level oversight. Can approve, reject, and view cross-team data. |
admin | 4 | Platform administration. User management, role assignment, system config. |
superadmin | 5 | Full access. Infrastructure operations, audit logs, role elevation. |
Roles are hierarchical: a manager can do everything an operator can do, plus manager-specific actions. A viewer cannot do anything an operator can do.
The DS platform does not support custom roles. Five roles cover every access pattern. If you think you need a sixth, you are likely conflating a role with a module-level permission. The permission matrix (below) handles module-level granularity within the five fixed roles.
The permission matrix
Each module defines what each role can do. The matrix is a single TypeScript object — not scattered across route files.
export type Role = 'viewer' | 'operator' | 'manager' | 'admin' | 'superadmin';
export type Action = 'read' | 'create' | 'update' | 'delete' | 'approve' | 'export';
export type PermissionMatrix = Record<string, Partial<Record<Action, Role>>>;import type { PermissionMatrix, Role } from '@/types/rbac';
export const ROLE_HIERARCHY: Record<Role, number> = { viewer: 1, operator: 2, manager: 3, admin: 4, superadmin: 5,};
export const PERMISSIONS: PermissionMatrix = { // Module: incidents 'incidents:read': { read: 'viewer' }, 'incidents:create': { create: 'operator' }, 'incidents:update': { update: 'operator' }, 'incidents:delete': { delete: 'admin' }, 'incidents:approve': { approve: 'manager' }, 'incidents:export': { export: 'manager' },
// Module: users 'users:read': { read: 'admin' }, 'users:create': { create: 'admin' }, 'users:update': { update: 'admin' }, 'users:delete': { delete: 'superadmin' },
// Module: audit-log 'audit-log:read': { read: 'admin' }, 'audit-log:export': { export: 'superadmin' },
// Module: reports 'reports:read': { read: 'viewer' }, 'reports:export': { export: 'operator' },};The value for each action is the minimum role required. A manager (level 3) passes a check for operator (level 2) because 3 >= 2.
When you add a module to the platform (Lesson 6), you add its entries to this matrix. The permission matrix is one of the five registration points for every new module.
The prompt
Build the RBAC enforcement layer for our Next.js 15.5 government platform.Requirements:
1. ROLE TYPES (src/types/rbac.ts) - Role type: 'viewer' | 'operator' | 'manager' | 'admin' | 'superadmin' - Action type: 'read' | 'create' | 'update' | 'delete' | 'approve' | 'export' - PermissionMatrix type mapping resource:action to minimum role - RoleLevel numeric mapping for hierarchy comparison
2. PERMISSION CHECK (src/lib/auth/permissions.ts) - ROLE_HIERARCHY object mapping each role to a numeric level (1-5) - hasPermission(userRole: Role, requiredRole: Role): boolean — returns true if user's level >= required level - checkAccess(userRole: Role, resource: string, action: Action): boolean — looks up the resource:action in PERMISSIONS matrix, gets minimum role, calls hasPermission - Export a PERMISSIONS constant with entries for: incidents, users, audit-log, reports (each with read/create/update/delete/approve/export mapped to appropriate minimum roles)
3. withPermission HOF (src/lib/auth/with-permission.ts) - Higher-order function that wraps a Next.js API route handler - Signature: withPermission(resource: string, action: Action, handler: AuthenticatedHandler) - Calls requireSession() to get the session - Calls checkAccess(session.role, resource, action) - If denied: return errorResponse('Forbidden', 403) - If allowed: call handler(request, session) - Type AuthenticatedHandler = (req: NextRequest, session: TypedSession) => Promise<NextResponse>
4. ROLE RESOLUTION (src/lib/auth/resolve-role.ts) - resolveRole() function implementing 4-step chain: Step 1: Check JWT role claim (from Azure AD token) Step 2: If null, query database for user's stored role Step 3: If null, check Azure AD group membership via Graph API Step 4: If null, assign 'viewer' as default - Each step logs which source resolved the role (for audit) - Cache resolved role in JWT for subsequent requests
Use the response helpers from src/lib/response.ts (errorResponse,listResponse). TypeScript strict mode. No any types. All errorresponses must use SafeError.What you get
withPermission() deep dive
The higher-order function is the core enforcement mechanism. Every API route on the platform is wrapped with it.
import { NextRequest, NextResponse } from 'next/server';import { requireSession } from './session';import { checkAccess } from './permissions';import { errorResponse } from '@/lib/response';import type { TypedSession } from './session';import type { Action } from '@/types/rbac';
type AuthenticatedHandler = ( req: NextRequest, session: TypedSession) => Promise<NextResponse>;
export function withPermission( resource: string, action: Action, handler: AuthenticatedHandler) { return async (req: NextRequest): Promise<NextResponse> => { // Step 1: Verify authentication const session = await requireSession(); // throws 401 if no session
// Step 2: Verify authorization if (!checkAccess(session.role, resource, action)) { return errorResponse('Forbidden', 403); }
// Step 3: Execute handler with verified session return handler(req, session); };}Three lines of enforcement logic. Authentication, then authorization, then execution. The handler never runs if either check fails.
An API route without withPermission() is an unprotected route. During security reviews, auditors grep for route files that do not call withPermission. If they find one, it is a finding. The pattern is non-negotiable: every exported GET, POST, PUT, PATCH, and DELETE must be wrapped.
The 4-step role resolution chain
When a user authenticates, their role must be determined. The platform uses a 4-step chain that tries increasingly expensive lookups:
Step 1: JWT claim └── Found? → Use it (fastest, no I/O)Step 2: Database lookup └── Found? → Use it, cache in JWTStep 3: Azure AD group membership └── Found? → Map group to role, cache in JWTStep 4: Default └── Assign 'viewer' (least privilege)import type { Role } from '@/types/rbac';import { getUserRole } from '@/lib/db/users';import { getGroupMembership } from './msal-config';
const GROUP_ROLE_MAP: Record<string, Role> = { 'DS-Platform-Admins': 'admin', 'DS-Platform-Managers': 'manager', 'DS-Platform-Operators': 'operator', 'DS-Platform-Viewers': 'viewer',};
export async function resolveRole( userId: string, jwtRole?: string, accessToken?: string): Promise<{ role: Role; source: string }> { // Step 1: JWT claim (no I/O) if (jwtRole && isValidRole(jwtRole)) { return { role: jwtRole as Role, source: 'jwt' }; }
// Step 2: Database lookup const dbRole = await getUserRole(userId); if (dbRole) { return { role: dbRole, source: 'database' }; }
// Step 3: Azure AD group membership if (accessToken) { const groups = await getGroupMembership(accessToken); for (const [groupName, role] of Object.entries(GROUP_ROLE_MAP)) { if (groups.includes(groupName)) { return { role, source: 'azure-ad-group' }; } } }
// Step 4: Default — least privilege return { role: 'viewer', source: 'default' };}
function isValidRole(role: string): role is Role { return ['viewer', 'operator', 'manager', 'admin', 'superadmin'].includes(role);}Different deployment stages resolve roles differently. In production, most users hit Step 1 (JWT claim cached from a previous resolution). During initial rollout, users may not have JWT claims yet, so Step 2 or 3 kicks in. Step 4 ensures no user is ever denied access entirely — they get the least-privileged role. The audit log records which step resolved the role, so you can track how often each path is taken.
Client-side conditional rendering
RBAC enforcement happens on the server. But the UI also needs to hide buttons and menu items that the user cannot act on. This is a UX concern, not a security concern — the server rejects unauthorized requests regardless of what the client renders.
'use client';
import { useSession } from 'next-auth/react';import { ROLE_HIERARCHY } from '@/lib/auth/permissions';import type { Role } from '@/types/rbac';
interface Props { minRole: Role; children: React.ReactNode; fallback?: React.ReactNode;}
export function RoleGate({ minRole, children, fallback = null }: Props) { const { data: session } = useSession(); const userRole = session?.role as Role | undefined;
if (!userRole) return fallback; if (ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[minRole]) return fallback;
return <>{children}</>;}Usage in a page:
<RoleGate minRole="manager"> <Button onClick={handleApprove}>Approve</Button></RoleGate>
<RoleGate minRole="admin"> <Button onClick={handleDelete} color="error">Delete</Button></RoleGate>RoleGate hides UI elements, but it does not protect resources. A user could call the API directly with curl or modify the client-side code. The real enforcement is withPermission() on the server. Never rely on client-side role checks alone.
Middleware-level role checks
The middleware (from Lesson 2) already reads the role from the JWT and sets it as a header. For coarse route-level blocking — entire sections that only admins should reach — the middleware can reject early:
// In middleware.ts — admin-only route protection
const ADMIN_PATHS = ['/admin', '/api/admin'];
if (ADMIN_PATHS.some((p) => pathname.startsWith(p))) { const role = token.role as string; if (ROLE_HIERARCHY[role as Role] < ROLE_HIERARCHY['admin']) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); }}This is a coarse filter. Fine-grained permission checks (can this manager approve incidents but not delete them?) happen in withPermission() at the route handler level.
A new user signs in for the first time. Their JWT has no role claim, they are not in the database users table, and their Azure AD account belongs to the 'DS-Platform-Operators' group. What role does the resolution chain assign?
Putting it all together
Here is the enforcement chain from request to response:
1. Browser sends request2. middleware.ts: validates JWT (Lesson 2), checks admin-only paths3. API route: withPermission('incidents', 'create', handler) a. requireSession() → TypedSession with role b. checkAccess('operator', 'incidents', 'create') → PERMISSIONS['incidents:create'] = { create: 'operator' } → hasPermission('operator', 'operator') → level 2 >= 2 → true c. handler(req, session) executes4. Response returnedA viewer hitting the same route:
3b. checkAccess('viewer', 'incidents', 'create') → hasPermission('viewer', 'operator') → level 1 >= 2 → false → errorResponse('Forbidden', 403)The handler never executes. The viewer gets a 403. The audit log records the denied attempt.
Key takeaways
- Five roles, hierarchical. viewer < operator < manager < admin < superadmin. Higher roles inherit all lower-role permissions.
- withPermission() wraps every API route. It is a higher-order function that handles authentication and authorization before the handler executes. No exceptions, no unwrapped routes.
- The permission matrix is centralized. All resource:action mappings live in one file. Adding a module means adding its entries to the matrix — not scattering permission checks across route files.
- 4-step role resolution tries JWT claim, database, Azure AD groups, then defaults to viewer. The resolved role is cached in the JWT to avoid repeated lookups.
- Client-side RoleGate is cosmetic. It hides buttons the user cannot use, but the real enforcement is server-side. Never trust the client for authorization.
What’s next
With authentication, the app shell, and RBAC in place, you have a secure platform foundation. The next lesson shifts to building features on top of it: scaffolding a new module with the seven required files that every DS platform feature needs.