All articles
  • Permission testing
  • Security testing
  • API testing

Testing Role-Based Permissions Without Missing a Cell

Permission bugs ship because testers sample instead of enumerating. Here is the matrix method I use, and the cells almost everyone forgets to fill in.

Muhammad Asadullah Kissana8 min read

A viewer-role user could cancel another operator's booking. The interface never offered them the button — the API simply never checked who owned the record. Nobody had tested that combination because nobody had written it down as a combination worth testing.

That is how nearly every permission defect reaches production. Not through a hard problem, but through a cell in a grid that no one enumerated.

Sampling is why permission bugs ship

The usual approach is to log in as each role, click around, and confirm nothing looks wrong. It feels thorough because you have used every account. It is not thorough, because permissions are not a list of roles — they are the product of roles and actions, and the interesting failures live in the cells you did not visit.

Four roles and twenty-five actions is a hundred cells. Clicking around covers perhaps thirty of them, always the same thirty: the ones on the happy path of each role's normal day. The defects sit in the other seventy.

Build the matrix before you test anything

Before opening the application, write the grid. Roles down the side, actions across the top, and one cell per pair. Do it in a spreadsheet — this is the rare case where a spreadsheet genuinely beats a test management tool, because you need to see the whole surface at once.

Getting the actions right matters more than getting them exhaustive. An action is a thing the system can do, not a screen:

  • Create, read, update, delete for each resource type
  • Read someone else's record of that type — a separate action, and the one that matters most
  • Export, bulk update, bulk delete
  • Change another user's role or invite a new user
  • Approve, cancel, refund, or anything else that moves money or state
  • View reports, audit logs, and settings

Then fill in the expected answer for every cell, from the requirements rather than from the application. If you cannot answer a cell, you have found a specification gap before writing a single test — which is the cheapest defect you will ever report.

Expect an argument over four or five cells. That argument is the deliverable. "Can a project manager delete an invoice?" is a business decision, and it is far better resolved in a spreadsheet review than in production.

Every cell has three possible answers, not two

This is the distinction that separates real permission testing from clicking around. For each cell, ask:

  1. Is the action visible to this role in the interface?
  2. Is the action blocked if they reach it anyway — by URL, by a stale tab, by a saved bookmark?
  3. Is the action blocked at the API, with no interface involved at all?

A hidden button satisfies the first and neither of the others. Hiding is a usability decision; it is not a security control. I have lost count of the systems where the admin screen was hidden from regular users but returned a full page of data to anyone who typed the URL.

So a cell is only genuinely tested when the answer at the API layer is right. Everything above that is presentation.

Test the API directly, because the UI hides the real answer

For every "must be denied" cell, send the request yourself. This is where the Postman checklist I run on every endpoint meets permission testing:

DELETE /api/invoices/8412
Authorization: Bearer <viewer-role-token>

The acceptable responses are 403 and 404. Anything in the 2xx range is a defect regardless of what the interface shows, and a 500 is worse — it means the endpoint reached logic it should never have reached.

Run it for every verb. The most common asymmetry I find is an endpoint that correctly refuses to show another user's record but happily accepts a PATCH or DELETE against it, because the read path got an ownership check during a security review and the write paths did not.

In Postman this is quick to keep honest: one environment per role, each holding that role's token, and a saved collection of the denial cases. Switching environment re-runs the whole set of "this must fail" requests against a different role. Those requests are worth more than the happy-path ones, because a happy path breaking is loud and a permission check disappearing is silent.

The cells nobody writes down

Once the obvious grid passes, these are where the remaining defects live:

Bulk endpoints. Single-record permissions are usually right because someone thought about them. The bulk update, bulk delete, and CSV import paths were added later and often skip the per-record ownership check entirely. A read-only role that can hit POST /api/invoices/bulk-update is a real finding.

Exports and reports. An export frequently runs its own query, built before the permission model existed. The screen shows a user their own five records; the CSV contains all four thousand.

Object IDs in unexpected places. A filter parameter, a sort field, a report id, a webhook payload. Anywhere a client supplies an identifier is a place ownership can go unchecked.

Role changes mid-session. Demote a user from admin to viewer while they still have a tab open, then have them act in that tab. If permissions were resolved once at login and cached in a token, they keep admin rights until it expires. This is worth testing specifically because the fix is architectural and nobody wants to find it late.

Deactivated and deleted users. A suspended account's token should stop working immediately, not at expiry.

Invitation and escalation paths. Can a role invite a user at a higher role than its own? Can it edit its own permissions? Can it transfer ownership of a record to itself? These three questions have found more in my experience than any amount of clicking through settings screens.

Keep the matrix, and treat it as the deliverable

The grid is not scaffolding you throw away after the release. It is the artefact.

Kept in version control alongside the test cases, it becomes the answer to "what is this role allowed to do?" — a question developers, product owners, and support all ask, and which otherwise gets answered from memory. When a new role is added, you add a row and you already know the hundred questions to ask. When an auditor asks how permissions are verified, you have something to show them.

Mark each cell with its expected answer and the date it was last verified. That second column is what makes the matrix trustworthy six months later, and it is what lets you say precisely which cells were re-tested this release and which were not — the same discipline that makes a regression strategy defensible under deadline pressure.

Why I run this test first

Permission defects have a worse profile than almost anything else a tester finds. A broken calculation is embarrassing and fixable. One user reading another user's data is a breach — reportable, potentially contractual, and impossible to undo once it has happened, because you cannot un-see data.

They are also the defects least likely to be found by anyone else. A developer testing their own feature uses their own admin account. A product owner checks the flow they described. A user never discovers they could have seen someone else's record. The only person positioned to find it is the tester who sat down with a grid and filled in every cell.

If you want this run properly against your product, that is a large part of what I do.

Work with Asadullah Kissana

Available for QA contracts, consulting, and full-time roles — and for web and mobile builds through aimEncoders.