Write your authorization unit tests as a table: one row per subject, action, resource and expected decision. A table forces you to list every role against every action, makes missing deny cases visible in review, and gives you a cheap way to extend coverage when a new role appears.
Why authorization tests go wrong
Most teams test the happy path. An admin can edit a project, so there is a test for it. Nobody writes the row that says a read-only member cannot, because the test is not needed to make the feature work.
Access control bugs live in exactly those missing rows. A unit test that only proves permitted actions succeed will pass even if every check is deleted.
Pull the decision into a pure function
Table tests work best when the rule is a function with no network and no database. Something like can(user, action, resource) that returns allow or deny. If your checks are scattered across handlers, extract them first. That refactor is the real security win, because one function is easier to review than forty inline conditions.
Keep the inputs small: role, owner id, tenant id, resource state. Those four cover most real rules.
Build the table
Here is a Python sketch using pytest parametrization.
import pytest
from authz import can
CASES = [
# role, user_tenant, res_tenant, owner, action, expected
("admin", "t1", "t1", "u2", "delete", True),
("admin", "t1", "t2", "u2", "delete", False),
("member", "t1", "t1", "u1", "edit", True),
("member", "t1", "t1", "u2", "edit", False),
("viewer", "t1", "t1", "u1", "edit", False),
("viewer", "t1", "t1", "u2", "read", True),
("anon", None, "t1", "u2", "read", False),
]
@pytest.mark.parametrize("role,ut,rt,owner,action,expected", CASES)
def test_authz(role, ut, rt, owner, action, expected):
assert can(role, ut, rt, owner, action) is expected
Notice that three of the seven rows expect a denial, and one of those is a cross tenant admin. That row is the one that catches a missing tenant filter.
Make the table exhaustive on purpose
Add a second test that generates the full cross product of roles, actions and tenant relationships, and fails if any combination has no row in the table. Then a new role or action cannot ship without someone deciding the answer.
Two cheap rules help:
- Default to deny. Add a row for an unknown action and assert it is refused.
- Test the boundaries of state, such as an archived resource or a suspended user.
What this does not cover
A table proves the decision function is right. It does not prove every endpoint calls it. Pair the unit table with a few integration tests that hit real routes as different users, and with a review habit that asks where the check is for each new route.
Try it yourself
Safeguard's pull request gate demo shows how a failing check blocks a merge. Notice how a red result stops the change before review, which is where a missing deny row should be caught.
Click through it at your own pace, or open it full screen.
Quick check
Is one test per role enough?
No. The risk is in the combinations of role, action and ownership or tenant. Test the cross product, and make sure denials are at least as common as permits in your table.
Should I mock the database for these tests?
For the decision function, you do not need a database at all. Pass plain values. Use a real database only in the integration tests that check the endpoints call the function.
If you want a pull request to be blocked automatically when a scan finds a problem, Safeguard policies and gates can do that, and you can run a free scan at https://gold.safeguard.sh/scan.
Last reviewed: 19 October 2026.