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.