Broken access control sits at the top of the OWASP Top 10 for a reason. It is also the class of bug that a QA team is best placed to find. It does not need exploit skills. It needs a clear statement of who should be able to do what, and tests that check the system agrees.
Most QA suites already test that the right user can do the right thing. The gap is the negative case: that the wrong user cannot. This guide shows how to make those cases systematic.
Start with a matrix
Write down every role your product has and every endpoint or action that matters. Fill each cell with the expected result. This is a requirements exercise as much as a test one, and it often surfaces disagreements nobody knew about.
| Endpoint | Anonymous | Viewer | Editor | Admin | Other tenant admin |
|---|---|---|---|---|---|
GET /projects/{id} | 401 | 200 | 200 | 200 | 404 |
PATCH /projects/{id} | 401 | 403 | 200 | 200 | 404 |
DELETE /projects/{id} | 401 | 403 | 403 | 204 | 404 |
POST /projects/{id}/members | 401 | 403 | 403 | 201 | 404 |
GET /admin/audit-log | 401 | 403 | 403 | 200 | 403 |
The last column is the one teams forget. "Other tenant admin" is a fully privileged user in a different organisation. They should get nothing from your objects. Many access control bugs only appear in that column, because every role check passes and only the ownership check is missing.
Set up the actors
The test environment needs one user per column, in at least two tenants, plus one object owned by the first tenant. Create them in a fixture so every test starts from the same state. Use throwaway test accounts that only exist in the test environment.
# conftest.py
import pytest
from api_client import Client, create_tenant, create_user, create_project
@pytest.fixture(scope="session")
def world():
a = create_tenant("tenant-a")
b = create_tenant("tenant-b")
users = {
"anonymous": None,
"viewer": create_user(a, role="viewer"),
"editor": create_user(a, role="editor"),
"admin": create_user(a, role="admin"),
"other_admin": create_user(b, role="admin"),
}
project = create_project(a, name="qa-fixture")
return {"users": users, "project": project}
def client_for(world, actor):
user = world["users"][actor]
return Client(token=user.token if user else None)
Turn the matrix into tests
Encode the matrix as data and let pytest generate one test per cell. When a requirement changes, you change one line in the table, not a test function.
# test_authz_projects.py
import pytest
from conftest import client_for
ACTORS = ["anonymous", "viewer", "editor", "admin", "other_admin"]
MATRIX = {
("GET", "/projects/{id}"): [401, 200, 200, 200, 404],
("PATCH", "/projects/{id}"): [401, 403, 200, 200, 404],
("DELETE", "/projects/{id}"): [401, 403, 403, 204, 404],
("POST", "/projects/{id}/members"): [401, 403, 403, 201, 404],
}
CASES = [
(method, path, actor, expected)
for (method, path), row in MATRIX.items()
for actor, expected in zip(ACTORS, row)
]
@pytest.mark.parametrize("method,path,actor,expected", CASES)
def test_project_authz(world, method, path, actor, expected):
client = client_for(world, actor)
url = path.format(id=world["project"].id)
resp = client.request(method, url, json={"name": "x"})
assert resp.status_code == expected, (
f"{actor} {method} {url}: got {resp.status_code}, want {expected}"
)
Run destructive cells such as DELETE against a fresh object per test, or order them last, so one passing delete does not break the cells after it.
Beyond status codes
A correct status code is necessary, not sufficient. Add a handful of checks that look at what comes back:
- List endpoints. A viewer listing projects sees only their tenant's projects. Assert on the ids, not the count.
- Field-level exposure. A viewer's response does not include fields such as billing details or member emails if the matrix says they should not.
- Mass assignment. An editor sending
"role": "admin"or"tenant_id"in aPATCHbody does not change those fields. - Indirect references. Child objects such as
/projects/{id}/files/{file_id}check that the file belongs to that project, not just that the project is accessible.
What to do on Monday
- Pick one resource, such as projects or invoices, and write its role by endpoint matrix with product.
- Add the "other tenant" column and agree the 403 or 404 rule.
- Create the fixture with one user per column across two tenants.
- Encode the matrix as data and run it in CI on every build.
- Add a new row whenever a story adds an endpoint. Link it to the story's abuse case, so the requirement and the test stay together.