Testing Authorization Policies Like the Boundary They Are
· Jerwin Arnado · 8 min read ·
The RBAC vs ABAC post ended with a caveat that deserves its own post: test policies like the security boundary they are. Most test suites are thorough about the happy path and vague about authorization — and the asymmetry is exactly backwards. A rendering bug ships a broken page; a policy bug ships a breach. “Users could see other departments’ records for three weeks” is not a changelog entry, it’s a disclosure email.
The good news: authorization is the easiest security property to test, because a policy is a pure function — attributes in, boolean out. Here’s the discipline, in Pest.
The policy under test
Carrying over the hybrid policy from part one — role for the capability class, attributes for the row:
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->can('posts.update')
&& $user->department_id === $post->department_id
&& $post->status !== PostStatus::Archived
&& $user->isCurrentlyEmployed();
}
}
Four conditions. That’s not one test — that’s a grid: each condition needs at least one case where it alone flips the answer to “no.”
Unit tests: table-driven over the attribute grid
Pest datasets are built for this. One test body, one named row per business rule:
function pair(array $user = [], array $post = []): array
{
$dept = Department::factory()->create();
return [
User::factory()->editor()->create(['department_id' => $dept->id, ...$user]),
Post::factory()->create(['department_id' => $dept->id, ...$post]),
];
}
it('decides post updates across the attribute grid', function (callable $scenario, bool $allowed) {
[$user, $post] = $scenario();
expect((new PostPolicy)->update($user, $post))->toBe($allowed);
})->with([
'own department, live post, employed' => [fn () => pair(), true],
'other department' => [fn () => tap(pair(), fn (&$p) =>
$p[1]->update(['department_id' => Department::factory()->create()->id])), false],
'archived post' => [fn () => pair(post: ['status' => PostStatus::Archived]), false],
'contract ended yesterday' => [fn () => pair(user: ['contract_ends_at' => now()->subDay()]), false],
'missing the capability' => [fn () => tap(pair(), fn ($p) =>
$p[0]->revokePermissionTo('posts.update')), false],
]);
Two things matter more than the syntax. Every row is named after the business rule, so
a failure reads as “contract ended yesterday → FAILED” — the requirement doc and the test
report are the same artifact. And there’s exactly one green row: with default-deny,
the happy path is the special case, and everything else must independently force a no.
When someone refactors isCurrentlyEmployed() and the contractor row goes green, the
suite catches what a code review skims past.
Feature tests: prove the 403 is wired
A unit-green policy protects nothing if the controller forgot to call it. That’s a depressingly common bug — the policy exists, the test passes, and the endpoint never asks. So a second, thinner layer hits the HTTP surface:
it('forbids updating another department\'s post over HTTP', function () {
$post = Post::factory()->create();
$outsider = User::factory()->editor()->create(); // different department by default
$this->actingAs($outsider)
->putJson(route('posts.update', $post), ['title' => 'nope'])
->assertForbidden();
expect($post->fresh()->title)->not->toBe('nope');
});
it('rejects guests outright', function () {
$post = Post::factory()->create();
$this->putJson(route('posts.update', $post), ['title' => 'nope'])
->assertUnauthorized(); // 401 — authn, before authz even runs
});
You don’t repeat the whole grid here — the unit layer owns the combinatorics. The feature
layer answers exactly two questions: is the policy connected to this route, and does a
denial actually prevent the write? That final fresh() assertion has caught real bugs:
a controller that returned 403 after saving.
Test the bypass — it’s the sharpest knife
Part one used Gate::before for the admin override. It’s also the easiest way to destroy
your entire authorization layer with one character:
// Correct: null means "no opinion, continue to the policy"
Gate::before(fn (User $user) => $user->hasRole('admin') ? true : null);
// Catastrophe: false short-circuits — every policy in the app is now dead code
Gate::before(fn (User $user) => $user->hasRole('admin'));
That second version returns false for every non-admin, and Gate::before treats any
non-null return as final — no policy ever runs again. All your policy unit tests still
pass, because they call the policy directly. Only a through-the-gate test catches it:
it('lets admins through the gate without a policy hit', function () {
$admin = User::factory()->admin()->create();
$post = Post::factory()->create(); // not the admin's department
expect($admin->can('update', $post))->toBeTrue();
});
it('does not short-circuit non-admins before the policy', function () {
[$user, $post] = pair(); // the grid's one green row
expect($user->can('update', $post))->toBeTrue(); // via Gate, not the policy class
});
If someone fat-fingers the before callback, the second test goes red instantly. Cheap
insurance against the most expensive one-liner in Laravel.
Caveats and best practices
- Spend your rows on denials. Default-deny means the “no” cases carry the security weight. A suite that’s 80% happy-path authorization tests is testing the wrong thing.
- Never mock the policy. Mocking
PostPolicyin a feature test asserts that your mock works. The entire point is exercising the real decision against real attributes. - Factories are the test’s vocabulary. States like
->editor(),->admin(),->contractorEndingYesterday()keep the grid readable. If building a denial case takes ten lines of setup, that’s friction that stops the next person adding one. - Every authorization incident becomes a permanent row. Post-mortem action item, every time: the exact attribute combination that leaked gets a named dataset entry. Regression tests are how the same hole never opens twice.
- Keep the grid at the unit layer. Policy-direct tests are milliseconds; through-HTTP tests are not. Combinatorics below, wiring above — the suite stays fast enough that nobody’s tempted to skip it, which is the same argument as the pre-commit hooks post.
Conclusion
Unit → the grid: one named row per rule, one green row, policy called directly
Feature → the wiring: 403 on the route, denial actually blocks the write
Gate → the bypass: before() returns null, admins pass, non-admins still hit policy
Ritual → every authz incident becomes a permanent dataset row
A policy is the cheapest security control you’ll ever test — a pure function with a boolean out. Name the rows after the rules, keep one path green, prove the 403 is wired, and put a tripwire on the admin bypass. Next in the series: what happens to all of this when one database serves many customers.