RBAC Testing: How to Test Role-Based Access Control Thoroughly

RBAC Testing: How to Test Role-Based Access Control Thoroughly

Role-based access control (RBAC) defines what each user role can do. Testing RBAC means verifying not just that permitted actions work, but that forbidden ones are properly blocked — including attempts to escalate privileges.

The Permission Matrix

Start by mapping every resource and action to allowed roles:

const PERMISSIONS = {
  projects: {
    list:   ['viewer', 'member', 'admin'],
    read:   ['viewer', 'member', 'admin'],
    create: ['member', 'admin'],
    update: ['member', 'admin'],
    delete: ['admin'],
  },
  users: {
    list:   ['admin'],
    invite: ['admin'],
    remove: ['admin'],
  },
  billing: {
    view:   ['admin'],
    update: ['admin'],
  },
}

This matrix drives test generation. Every cell is a test case.

Generating Tests from the Permission Matrix

// rbac.test.js
import { describe, test, expect } from 'vitest'

const roles = ['viewer', 'member', 'admin']

const endpoints = [
  { method: 'GET',    path: '/api/projects',       action: 'projects.list',   allowed: ['viewer', 'member', 'admin'] },
  { method: 'POST',   path: '/api/projects',       action: 'projects.create', allowed: ['member', 'admin'] },
  { method: 'DELETE', path: '/api/projects/proj-1', action: 'projects.delete', allowed: ['admin'] },
  { method: 'GET',    path: '/api/users',          action: 'users.list',      allowed: ['admin'] },
  { method: 'GET',    path: '/api/billing',        action: 'billing.view',    allowed: ['admin'] },
]

for (const endpoint of endpoints) {
  for (const role of roles) {
    const shouldAllow = endpoint.allowed.includes(role)
    const expectedStatus = shouldAllow ? [200, 201] : [403]
    
    test(`${role} ${shouldAllow ? 'can' : 'cannot'} ${endpoint.action}`, async () => {
      const token = await getTokenForRole(role)
      
      const response = await request(app)[endpoint.method.toLowerCase()](endpoint.path)
        .set('Authorization', `Bearer ${token}`)
      
      expect(expectedStatus).toContain(response.status)
    })
  }
}

Boundary Tests

Test the boundaries — the exact line between permitted and forbidden:

describe('RBAC boundaries', () => {
  test('member can update project settings but not delete', async () => {
    const memberToken = await getTokenForRole('member')
    
    // Allowed
    const updateRes = await request(app)
      .patch('/api/projects/proj-1')
      .set('Authorization', `Bearer ${memberToken}`)
      .send({ name: 'Updated Name' })
    expect(updateRes.status).toBe(200)
    
    // Not allowed
    const deleteRes = await request(app)
      .delete('/api/projects/proj-1')
      .set('Authorization', `Bearer ${memberToken}`)
    expect(deleteRes.status).toBe(403)
  })
  
  test('viewer can read but not write', async () => {
    const viewerToken = await getTokenForRole('viewer')
    
    const readRes = await request(app)
      .get('/api/projects/proj-1')
      .set('Authorization', `Bearer ${viewerToken}`)
    expect(readRes.status).toBe(200)
    
    const writeRes = await request(app)
      .patch('/api/projects/proj-1')
      .set('Authorization', `Bearer ${viewerToken}`)
      .send({ name: 'Attempt' })
    expect(writeRes.status).toBe(403)
  })
})

Privilege Escalation Tests

Test that users cannot elevate their own permissions:

describe('Privilege escalation prevention', () => {
  test('member cannot promote themselves to admin', async () => {
    const memberToken = await getTokenForRole('member', { userId: 'user-member' })
    
    const response = await request(app)
      .patch('/api/users/user-member')
      .set('Authorization', `Bearer ${memberToken}`)
      .send({ role: 'admin' })
    
    expect(response.status).toBe(403)
    
    const user = await db.users.findById('user-member')
    expect(user.role).toBe('member')
  })
  
  test('member cannot change another user\'s role', async () => {
    const memberToken = await getTokenForRole('member')
    
    const response = await request(app)
      .patch('/api/users/user-other')
      .set('Authorization', `Bearer ${memberToken}`)
      .send({ role: 'viewer' })
    
    expect(response.status).toBe(403)
  })
  
  test('admin cannot change their own role', async () => {
    // Prevent accidental lockout
    const adminToken = await getTokenForRole('admin', { userId: 'admin-user' })
    
    const response = await request(app)
      .patch('/api/users/admin-user')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ role: 'viewer' })
    
    expect(response.status).toBe(400)  // Prevent self-demotion
  })
})

Testing JWT Claims

If roles are encoded in JWTs, test that tampered tokens are rejected:

import jwt from 'jsonwebtoken'

test('tampered role claim is rejected', async () => {
  const validToken = await loginAs('viewer')
  
  // Decode without verifying signature
  const payload = jwt.decode(validToken)
  
  // Forge an admin token
  const forgedToken = jwt.sign(
    { ...payload, role: 'admin' },
    'wrong-secret'  // Not the real signing key
  )
  
  const response = await request(app)
    .delete('/api/projects/proj-1')
    .set('Authorization', `Bearer ${forgedToken}`)
  
  expect(response.status).toBe(401)
})

test('expired token is rejected', async () => {
  const expiredToken = jwt.sign(
    { userId: 'u1', role: 'admin' },
    process.env.JWT_SECRET,
    { expiresIn: '-1s' }  // Already expired
  )
  
  const response = await request(app)
    .get('/api/projects')
    .set('Authorization', `Bearer ${expiredToken}`)
  
  expect(response.status).toBe(401)
})

Testing Resource-Level Permissions

Some systems have permissions at the resource level, not just role level:

describe('Resource-level permissions', () => {
  test('project owner can delete, non-owner member cannot', async () => {
    // userA is the project owner
    const project = await db.projects.create({
      name: 'Test',
      tenantId: 'tenant-1',
      ownerId: 'user-a'
    })
    
    // Non-owner member
    const memberToken = await getTokenForUser('user-b', { role: 'member' })
    
    const deleteRes = await request(app)
      .delete(`/api/projects/${project.id}`)
      .set('Authorization', `Bearer ${memberToken}`)
    
    expect(deleteRes.status).toBe(403)
    
    // Owner can delete
    const ownerToken = await getTokenForUser('user-a', { role: 'member' })
    
    const ownerDeleteRes = await request(app)
      .delete(`/api/projects/${project.id}`)
      .set('Authorization', `Bearer ${ownerToken}`)
    
    expect(ownerDeleteRes.status).toBe(200)
  })
})

Testing RBAC in Background Jobs

Jobs run outside the HTTP request context — verify they enforce permissions too:

test('background export job only exports own tenant data', async () => {
  await db.records.createMany([
    { tenantId: 'tenant-a', value: 'A data' },
    { tenantId: 'tenant-b', value: 'B data' },
  ])
  
  const exportResult = await exportJobHandler({ tenantId: 'tenant-a', requestedBy: userA.id })
  
  expect(exportResult.records).toHaveLength(1)
  expect(exportResult.records[0].value).toBe('A data')
})

Python: Django Permission Tests

from django.test import TestCase
from django.contrib.auth.models import User, Permission

class RBACTest(TestCase):
    def setUp(self):
        self.admin = User.objects.create_user('admin', is_staff=True)
        self.member = User.objects.create_user('member')
        self.viewer = User.objects.create_user('viewer')
    
    def test_admin_can_delete_project(self):
        self.client.force_login(self.admin)
        response = self.client.delete(f'/api/projects/{self.project.id}/')
        self.assertEqual(response.status_code, 200)
    
    def test_viewer_cannot_delete_project(self):
        self.client.force_login(self.viewer)
        response = self.client.delete(f'/api/projects/{self.project.id}/')
        self.assertEqual(response.status_code, 403)

Summary

RBAC tests should be systematic, not ad-hoc. Build a permission matrix and generate tests from it — every role × every action is a test case. Test boundaries (what's allowed vs what's not). Test privilege escalation prevention. Test that token tampering is rejected. Resource-level and background job permissions need the same rigor as HTTP endpoint permissions.

Read more