Safeguard
Security Guides•

Table-driven unit tests for authorization rules

Write authorization checks as a table of who, action, resource and expected result, so every role gets tested and a deny case is never forgotten.

Safeguard Security Team
4 min read

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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.