Product: RBAC

Roles (built-in)

Role Description
super_admin Full control of the tenant
admin_l1 Manages cycles, creates other admins, full analytics
admin_l2 Posts jobs, manages verifiers, scoped to assigned cycles
verifier Reviews and approves student content for assigned students only
student Own data only

Custom roles can be created in the roles table with a custom permissions array. The system uses permission-based checks, not role-name checks, so custom roles work everywhere.


Permissions matrix

Student management

Permission super_admin admin_l1 admin_l2 verifier student
students:read (all students)
students:read_assigned (own assigned students)
students:create
students:update
students:delete
students:bulk_import
students:export
profile:read_own
profile:update_own

Placement cycles

Permission super_admin admin_l1 admin_l2 verifier student
cycles:create
cycles:read ✓ (assigned only) ✓ (eligible)
cycles:update
cycles:delete
cycles:manage_enrollment
cycles:bulk_enroll

Jobs

Permission super_admin admin_l1 admin_l2 verifier student
jobs:create
jobs:read ✓ (eligible)
jobs:update
jobs:delete
jobs:toggle_applications

Applications

Permission super_admin admin_l1 admin_l2 verifier student
applications:create_own
applications:read (all) ✓ (assigned cycles)
applications:read_own
applications:update_status
applications:bulk_download
applications:send_to_recruiter

Verifications

Permission super_admin admin_l1 admin_l2 verifier student
verifications:read (assigned)
verifications:approve
verifications:reject
verifications:assign
verifications:request

Events

Permission super_admin admin_l1 admin_l2 verifier student
events:create
events:read
events:update
events:delete
events:mark_attendance
events:exceptions:review
events:exceptions:request

Analytics + Intel

Permission super_admin admin_l1 admin_l2 verifier student
analytics:read
intel:query
intel:export

Tenant configuration

Permission super_admin admin_l1 admin_l2 verifier student
tenant:config:read
tenant:config:update
tenant:domain_rules:update
tenant:allowlist:manage
tenant:feature_flags:update

User / role management

Permission super_admin admin_l1 admin_l2 verifier student
roles:assign:super_admin
roles:assign:admin_l1
roles:assign:admin_l2
roles:assign:verifier
roles:assign:student
users:deactivate

API keys

Permission super_admin admin_l1 admin_l2 verifier student
api_keys:create
api_keys:revoke
api_keys:list

API key scopes

When a super admin creates an API key, they select which permission scopes it carries. API keys can never have more permissions than admin_l2 by default. Suggested key scopes:

Scope name Permissions included
student_data_readonly students:read, profile:read_own
jobs_readonly jobs:read, cycles:read
applications_readonly applications:read
full_readonly All :read permissions
student_write students:read, students:create, students:update
webhooks Receive webhook events (no DB reads — outbound only)

Role hierarchy enforcement

When assigning a role, the assigner can only assign roles at their level or below:

const ROLE_HIERARCHY: Record<UserRole, number> = {
  super_admin: 5,
  admin_l1: 4,
  admin_l2: 3,
  verifier: 2,
  student: 1,
};
 
function canAssignRole(assignerRole: UserRole, targetRole: UserRole): boolean {
  return ROLE_HIERARCHY[assignerRole] > ROLE_HIERARCHY[targetRole];
  // super_admin (5) can assign admin_l1 (4) ✓
  // admin_l1 (4) can assign admin_l2 (3) ✓
  // admin_l2 (3) cannot assign admin_l1 (4) ✗
}

Permission check in core-api middleware

// middleware/require-permission.ts
export function requirePermission(permission: string) {
  return async (req: FastifyRequest, reply: FastifyReply) => {
    const userPermissions = req.user.permissions;
    
    // Wildcard check for super_admin
    if (userPermissions.includes('*')) return;
    
    // Exact match
    if (userPermissions.includes(permission)) return;
    
    // Partial match: "students:read_assigned" satisfies "students:read_assigned"
    // but NOT "students:read"
    
    reply.code(403).send({
      error: 'forbidden',
      message: `Missing required permission: ${permission}`,
    });
  };
}
 
// Usage on a route:
app.get('/tenants/:slug/students', {
  preHandler: [requirePermission('students:read')],
}, handler);

For verifier accessing students: the verifier has students:read_assigned. The handler additionally filters to WHERE id IN (SELECT student_id FROM verifier_assignments WHERE verifier_id = req.user.id).