Skip to content

fix: let a record's policy decide gate checks on that record - #76

Closed
shavonn wants to merge 1 commit into
mainfrom
fix/gate-defer-to-policies
Closed

shavonn wants to merge 1 commit into
mainfrom
fix/gate-defer-to-policies

Conversation

@shavonn

@shavonn shavonn commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Problem

Gate::before returned true whenever the user held the permission. A before callback that returns true ends the check, so a record's policy never ran for a Mandate permission:

// UserPolicy::updateStatus refuses your own account — but this passed:
$admin->can('user:update_status', $admin); // true

#[Authorize('user:update_status', [User::class, 'user'])] // same: policy skipped

Denials were unaffected (without the permission the callback returns null), so the gap was one-directional: holding a permission granted past any extra rule a policy adds for a specific record — "not your own account", "not a super admin". Found in an app where an admin could deactivate their own account through the single action.

Change

When the user holds the permission and the arguments include a record whose policy has a method for the permission's action, that method decides:

  • the action after the last : names the method in camel case — team:edit → edit, user:update_status → updateStatus;
  • the policy is resolved from the first argument, as Laravel does, so #[Authorize('x', [Model::class, 'param'])] (class first, bound record second) is covered;
  • the method is asked through Gate::inspect, so the policy's own before() and Response denials behave normally.

Unchanged — still decided by the permission:

  • checks with no record (can('team:edit'), or a class name alone such as Team::class, where a method expecting a record would be called without one);
  • records with no policy, or a policy with no method for the action;
  • a method named exactly like the permission (asking it would only re-enter this callback).

mandate.gate.defer_to_policies (default true) restores the old behaviour.

Upgrade note

This is a behaviour change for apps relying on a held permission to bypass a record's policy. Any can() / #[Authorize] on a record now applies that policy's rules. Worth running a consuming app's suite on the bump; clari's full suite (778 tests) passes against it.

Tests

tests/Feature/GatePolicyTest.php with a TeamPolicy fixture: the policy refusing a record, the class-then-record argument shape, snake → camel method mapping, no permission, no record, no matching method, no policy, and the switch turned off. Full suite: 622 passed; PHPStan clean. PHP 8.2-compatible (no array_any).

README documents the behaviour under Laravel Gate Integration and adds the config row.

🤖 Generated with Claude Code

Mandate's Gate::before returned true whenever the user held the permission,
which ends the check before any policy runs. A check naming a record —
`$user->can('user:update_status', $target)` or
`#[Authorize('user:update_status', [User::class, 'user'])]` — therefore
skipped the policy's own rules (not your own account, not a super admin).

When the user holds the permission and the arguments include a record whose
policy has a method for the permission's action (`update_status` →
`updateStatus`), that method now decides. Checks without a record, records
without a policy, and policies without a matching method are still decided
by the permission. `mandate.gate.defer_to_policies` turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shavonn shavonn closed this Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant