ATALAIA
  1. Home
  2. Resources
  3. Blog
  4. Authorisation tests your QA team can own
Testing · Guide

Authorisation tests your QA team can own

Broken access control is the bug QA is best placed to catch. A role by endpoint matrix and a few lines of pytest make it routine.

3 min readAtalaia team

QAAuthorisationpytestAPI testing

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.

EndpointAnonymousViewerEditorAdminOther tenant admin
GET /projects/{id}401200200200404
PATCH /projects/{id}401403200200404
DELETE /projects/{id}401403403204404
POST /projects/{id}/members401403403201404
GET /admin/audit-log401403403200403

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 a PATCH body 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

  1. Pick one resource, such as projects or invoices, and write its role by endpoint matrix with product.
  2. Add the "other tenant" column and agree the 403 or 404 rule.
  3. Create the fixture with one user per column across two tenants.
  4. Encode the matrix as data and run it in CI on every build.
  5. 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.

Where this sits on the line

Talk it through

A 30-minute call. No slides, no price list, and a next step either way.