Test Access Control as a Matrix
Enumerate role, ownership, and tenant combinations, assert 401 versus 403 versus 404, and catch broken object-level authorization.
Task
Cover every role and ownership combination for sensitive endpoints so a missing check fails a test instead of shipping.
When to use this
An invoice endpoint verifies that the caller is logged in but not that the invoice belongs to them. Any authenticated user can read another customer's invoice by changing the id.
Prerequisites
- Node.js 22 with a test database
- At least two tenants and three roles (viewer, owner, admin)
- A request helper that can act as a given user
Steps
1. Write the matrix as data so every case has an explicit expected status.
const cases = [
{ as: 'anonymous', expected: 401 },
{ as: 'bob-other-tenant', expected: 404 },
{ as: 'carol-same-tenant', expected: 403 },
{ as: 'alice-owner', expected: 200 },
{ as: 'dave-admin', expected: 200 },
];
Expected output: Five cases per endpoint; the table is the authorization specification and is reviewed like code.
2. Seed two tenants, a non-owner colleague, an owner, and an admin.
const alice = await createUser({ tenant: 't1', role: 'viewer' });
const carol = await createUser({ tenant: 't1', role: 'viewer' });
const bob = await createUser({ tenant: 't2', role: 'viewer' });
const dave = await createUser({ tenant: 't1', role: 'admin' });
const invoice = await createInvoice({ tenant: 't1', ownerId: alice.id });
Expected output: Alice owns the invoice, Carol shares her tenant but not ownership, Bob is in another tenant, and Dave is an admin.
3. Run the matrix against the read endpoint and include the case name in the failure message.
for (const c of cases) {
const res = await request(`/api/invoices/${invoice.id}`, { as: c.as });
assert.equal(res.status, c.expected, `case ${c.as}`);
}
Expected output: Anonymous gets 401, Bob 404, Carol 403, Alice and Dave 200; any deviation names the failing case.
4. Repeat the matrix for writes and for the list endpoint, not just direct reads.
const patch = await request(`/api/invoices/${invoice.id}`, {
method: 'PATCH', as: 'bob-other-tenant', body: { totalCents: 1 },
});
assert.equal(patch.status, 404);
const list = await request('/api/invoices', { as: 'bob-other-tenant' });
assert.equal(list.body.data.length, 0);
Expected output: Bob cannot update the invoice or discover it through the list, so the check is enforced at the query, not the UI.
5. Verify the endpoint fails closed for missing or malformed identity.
assert.equal((await request('/api/invoices', { as: 'missing-role' })).status, 403);
assert.equal((await request('/api/invoices', { as: 'forged-token' })).status, 401);
Expected output: A missing role claim returns 403, and an invalid signature returns 401 before any business logic runs.
Troubleshooting
Problem
The team disagrees about 403 versus 404 for other tenants
Solution
Choose one policy, usually 404 for cross-tenant resources to prevent id enumeration, and assert it consistently in the matrix.
Problem
Tests interfere because users are shared
Solution
Create users and resources per test inside a rolled-back transaction; never depend on seeded ids or previous cases.
Problem
Admin tests pass while viewer access is broken
Solution
Run the full matrix for every role. Admin bypasses hide missing checks, so the least-privileged role is the important one.
Limitations
- The matrix grows with roles and resources; generate cases from a policy table when it becomes large.
- It verifies server-side authorization, not whether the UI hides actions it should not offer.
- Rules that depend on data attributes, such as region or amount, need additional cases beyond role and ownership.
Object-level authorization is the usual gap
Most teams test that anonymous users are blocked and that an admin can do everything. The vulnerability lives in the middle: an authenticated user with a valid session requests an object that belongs to someone else. The endpoint checks authentication, then queries by id and forgets the tenant or owner predicate. The UI never offers that id, so manual testing misses it, and a script finds it in minutes.
Turn the policy into a table
Write the cases as data: actor, resource ownership, expected status. The table becomes reviewable documentation and a checklist for new endpoints. When a new role is added, the diff to the table is visible in review. When an endpoint is added without matrix coverage, the gap is obvious rather than assumed.
const cases = [
{ as: 'anonymous', expected: 401 },
{ as: 'bob-other-tenant', expected: 404 },
{ as: 'carol-same-tenant', expected: 403 },
{ as: 'alice-owner', expected: 200 },
];
Test queries, not just handlers
A read endpoint that filters correctly may still leak through the list endpoint, an export, or a nested resource such as /invoices/1/lines. Run the matrix against every route that accepts an object id, including writes and batch operations. If authorization is enforced in a repository helper, one test for the helper plus one per route is enough; if each handler reimplements the filter, each handler needs the matrix.
Choose a consistent disclosure policy
Returning 403 for another tenant’s object confirms that the object exists. Returning 404 hides it. Neither is universally right, but the choice must be deliberate and consistent, because clients and tests depend on it. The common pattern is 404 for cross-tenant ids and 403 for same-tenant insufficient role, which is what the matrix above encodes.
Fail closed
Missing claims, unknown roles, and tokens with an invalid signature must all be rejected before the handler runs. Test these explicitly: a middleware that defaults to “viewer” when the claim is absent is a silent privilege grant. The safest default is deny, with an error that is logged and counted so a broken identity provider is visible immediately.