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).