How to Test Role-Based Access Control Matrices
Short answer
RBAC spans roles, resources, actions, and inheritance—hiding a button is not authorization. Seed fixture users per role, probe Assert HTTP 403 on forbidden API calls, and cover role × resource × action combinations that match prod—not one admin login that never tests viewer or cross-tenant denial.
Part of Testing Guides by auth and identity.
Who this is for
B2B SaaS and internal admin teams shipping role-based permissions (custom roles, Casbin, OPA, AWS IAM-style policies, Auth0 RBAC, Firebase custom claims, Postgres RLS-backed APIs) who need Playwright E2E that proves enforcement, not just UI visibility.
Typical stacks: Next.js admin + API middleware, NestJS guards, Django permissions, Supabase RLS, multi-tenant org roles.
Why testing RBAC matters
Authorization bugs are silent data breaches:
- Revenue loss — free-tier user calls
/api/exportand downloads enterprise feature; upsell bypass without audit trail. - Security incidents — IDOR on
/api/orgs/{id}/usersbecause middleware checks role but not resource ownership; editor deletes billing settings via direct API. - Support load — "I can't see the button" tickets when role was wrong vs true 403; inherited group membership lag after Okta sync.
- Compliance exposure — SOC2 control requires segregation of duties—same user can approve and pay; auditor requests matrix proof tests exist.
E2E must probe APIs for 403/404 on forbidden actions. Hidden nav items alone prove nothing—attackers use curl.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Role × resource × action | Combinatorial explosion | Only admin tested | Priority matrix from prod |
| UI hide vs API enforce | Button hidden, API open | False confidence | Direct API probe |
| IDOR | Role OK, wrong resource id | Cross-tenant leak | User A probes User B resource |
| Inherited roles | Group → role mapping | Flaky after sync | Seed group membership in Arrange |
| Custom claims lag | Token stale after role change | Pass then fail in prod | Force token refresh |
| Default role on signup | OAuth user gets admin | Privilege escalation | Probe new user permissions |
| Impersonation / support mode | Admin views as user | Over-broad access | Scoped impersonation spec |
| Field-level permissions | Read OK, write field denied | Partial leak | PATCH with forbidden field |
| Time-bound roles | Temp admin expires | Eternal access | Playwright clock |
| Deny overrides allow | Explicit deny wins | Wrong union logic | User with deny rule spec |
| Multi-tenant RBAC | Org admin ≠ app admin | Cross-org API | org_id scoped probes |
| RLS (Postgres) | API OK but SQL leak | ORM bypass | Two DB role tests |
| Webhook / API key scopes | Key lacks write | Automation hole | M2M negative probe |
| Client-side route guard | SPA only | Deep link bypass | goto /admin directly |
| SSR vs CSR mismatch | Server renders admin nav | Hydration flash | Probe on first load |
| Audit log on deny | Silent 403 | Compliance gap | Assert audit row |
| Feature flags + RBAC | Flag on bypasses role | Untested combo | Matrix flag × role |
Permission matrix methodology
Build a compact matrix—not every theoretical combo, but every prod-critical tuple:
| Role | Resource | Action | Expected | Test priority |
|---|---|---|---|---|
| viewer | reports | read | 200 | P0 |
| viewer | reports | delete | 403 | P0 |
| editor | reports | delete | 200 | P0 |
| editor | billing | read | 403 | P0 |
| org_admin | members | invite | 200 | P0 |
| org_admin | other_org members | invite | 403 | P0 |
Derive priorities from TrueCoverage role and permission dimensions or product analytics on admin action frequency.
Store matrix in markdown test plans; link SmartTests with // @Scenario: (requirement traceability).
Fixture users per role
Never reuse one admin session for all specs:
// playwright/fixtures/rbac.ts
type Role = 'viewer' | 'editor' | 'org_admin' | 'billing_admin';
export const test = base.extend<{ role: Role; authedPage: Page }>({
role: ['viewer', { option: true }],
authedPage: async ({ page, request, role }, use) => {
const runId = `${test.info().parallelIndex}-${Date.now()}`;
await request.post('/api/test/seed-user', {
data: { runId, role, orgId: `org-${runId}` },
});
await page.goto(`/test/login-as?runId=${runId}`);
await use(page);
},
});
test('viewer cannot delete reports', async ({ authedPage, request, role }) => {
test.skip(role !== 'viewer', 'viewer-only spec');
const res = await request.delete('/api/reports/report-123');
expect(res.status()).toBe(403);
});
Parameterize with test.describe per role or use project dependencies in Playwright config.
Seed route contract
// POST /api/test/seed-user
// Body: { runId, role, orgId, customClaims?: object }
// Creates user, assigns role in DB/Auth0/Firebase claims, returns session cookie URL
Mirror production role assignment path—if prod uses Auth0 Roles, seed via Management API (Auth0 guide), not DB-only shortcut that skips middleware.
Probe Assert pattern (mandatory)
For each forbidden tuple:
test('editor cannot access billing API', async ({ request }) => {
await loginAs(request, { role: 'editor', runId });
const res = await request.get('/api/billing/invoices');
expect(res.status()).toBe(403);
const body = await res.json();
expect(body.code).toMatch(/FORBIDDEN|INSUFFICIENT_PERMISSION/);
});
For allowed tuples, probe 200 + correct data scope—not only status:
const res = await request.get('/api/reports');
expect(res.status()).toBe(200);
const ids = (await res.json()).map((r: { id: string }) => r.id);
expect(ids).not.toContain('other-org-report-id');
IDOR and cross-tenant tests
RBAC without resource ownership checks fails open:
test('org A admin cannot read org B member list', async ({ request }) => {
const { orgA, orgB, adminA_session } = await seedTwoOrgs(runId);
const res = await request.get(`/api/orgs/${orgB}/members`, {
headers: adminA_session,
});
expect(res.status()).toBe(403); // or 404 to avoid enumeration
});
Include horizontal (same role, wrong resource) and vertical (lower role, own resource) cases.
UI tests (secondary)
UI specs verify UX, not security:
| Check | Purpose |
|---|---|
| Admin nav visible for admin | UX regression |
| Delete button absent for viewer | UX—not security proof |
Direct /admin URL for viewer | Should redirect or 403 page |
test('viewer visiting /admin sees forbidden page', async ({ authedPage }) => {
await authedPage.goto('/admin/users');
await expect(authedPage.getByText(/not authorized|403/i)).toBeVisible();
expect((await authedPage.request.get('/api/admin/users')).status()).toBe(403);
});
Always pair UI navigation with API probe in same spec.
Role change mid-session
When admin demotes a user, existing JWT may still carry old claims until refresh:
test('demoted user loses access after token refresh', async ({ page, request }) => {
const { uid, session } = await seedUser({ role: 'editor' });
expect((await request.get('/api/reports/draft', { headers: session })).status()).toBe(200);
await request.post('/api/test/set-role', { data: { uid, role: 'viewer' } });
// Still 200 if token stale (document bug or force refresh policy)
await page.evaluate(async () => {
const auth = (await import('firebase/auth')).getAuth();
await auth.currentUser?.getIdToken(true);
});
expect((await request.get('/api/reports/draft', { headers: session })).status()).toBe(403);
});
Align test with your claim refresh SLA (immediate revoke vs next login).
Combinatorial explosion — pragmatic coverage
You cannot E2E every roles × resources × actions. Strategy:
- P0 — all deny rules for sensitive resources (billing, delete, PII export)
- P1 — allow rules for each role's primary job function
- P2 — inheritance and edge denies
- Unit/policy tests — Casbin/OPA matrices in CI for exhaustive combos
- One E2E per boundary — integration proof unit tests match production wiring
Use /testchimp evolve when TrueCoverage shows a permission slice with prod traffic but zero test scenarios.
Policy-as-code alignment
If using OPA, Casbin, or AWS Cedar:
- Run policy unit tests in CI (fast, exhaustive)
- E2E validates middleware loads policy and JWT → role extraction correctly
- Single E2E: change policy file deny rule → redeploy → probe confirms 403 (optional nightly)
CI checklist
- Fixture user per role—unique runId per worker
- Every P0 matrix row has API probe spec
- IDOR cross-tenant negative tests for multi-tenant apps
- Token refresh after role change if prod revokes that way
- No single shared admin
storageStatefor entire suite - UI + API paired on
/admindeep links - Matrix documented in markdown with scenario links
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Only test admin happy path | Viewer bypass undetected | Matrix per role |
| Assert button not visible | API still allows DELETE | Probe 403 |
| Hard-code one resource id | IDOR untested | Cross-tenant ids |
| DB seed role but not token | Middleware reads JWT | Mirror prod assign path |
| Skip deny audit logs | Compliance gap | Assert audit on 403 |
| 1000 UI permutations | Slow, brittle | Policy unit + P0 E2E |
| Role in localStorage only | Trivial bypass | Server-side enforce |
Example scenario
Situation: Viewer role user crafts DELETE /api/reports/{id} although UI hides delete button.
Expected outcome: 403 Forbidden—server rejects regardless of UI.
Why UI-only automation breaks: Spec asserts delete button not visible—API returns 204—test passes.
- Arrange: Seed viewer user with session cookie for org A.
- Act: request.delete('/api/reports/sensitive-report-id') without UI.
- Assert: 403 with FORBIDDEN code; report still exists via admin probe; audit log records denied delete.
TestChimp workflow: Instrument api_denied with role, permission, resource; compare prod deny rate vs test coverage.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Connect scenarios to your QA workflow
Capture business rules in markdown test plans and enforce them with seed routes and probe Assert. Link SmartTests with // @Scenario: for requirement traceability. Use /testchimp test on PRs; /testchimp explore on SmartTest paths for non-functional gaps (ExploreChimp).
Related scenarios
- Auth0 and Okta SSO — IdP groups → app roles
- Firebase Authentication — custom claims
- MFA / 2FA — MFA required for admin role
- Session timeout — session vs permission revoke
- OAuth social login — default role for new OAuth users
- Multi-tenant patterns — org isolation (if available)
External references
- OWASP Authorization Cheat Sheet
- OWASP IDOR prevention
- Auth0 RBAC
- Firebase custom claims
- Open Policy Agent testing
- Casbin syntax
Frequently asked questions
Is hiding admin buttons enough for RBAC testing?
No. Attackers and bugs bypass UI. Every forbidden action needs an API probe expecting 403. UI tests are supplementary UX checks—pair them with probes in the same spec.
How do I avoid testing thousands of role combinations?
Build a priority matrix from prod TrueCoverage on role and permission. Run exhaustive combos in Casbin/OPA unit tests; E2E covers P0 deny rules and one allow path per role.
How do I test IDOR separately from role checks?
Use two tenants and two users with the same role. User A should get 403 or 404 accessing User B resource IDs even when role would allow access within own tenant.
Should each Playwright test use a different role session?
Yes—use fixtures that seed role per spec or project config. Do not reuse admin storageState for viewer tests; that hides entire authorization classes.
How do I test role changes take effect?
Demote user via test API, force ID token refresh if your app uses JWT claims, then probe previously allowed endpoint returns 403. Match production revoke SLA in assertion timing.
We use Postgres RLS—do we still need E2E RBAC tests?
Yes—one E2E per critical boundary proves API middleware and RLS align. Unit tests on policies alone miss wiring bugs in how JWT maps to DB role.
How does TestChimp help maintain RBAC matrices?
TrueCoverage highlights role and permission slices with prod traffic but no linked scenarios. Use /testchimp evolve to add probe specs for new matrix rows and // @Scenario: traceability for SOC2 evidence—not manual spreadsheet drift.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.