Repository navigation
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Gate::beforereturnedtruewhenever the user held the permission. Abeforecallback that returnstrueends the check, so a record's policy never ran for a Mandate permission: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:
:names the method in camel case —team:edit→edit,user:update_status→updateStatus;#[Authorize('x', [Model::class, 'param'])](class first, bound record second) is covered;Gate::inspect, so the policy's ownbefore()andResponsedenials behave normally.Unchanged — still decided by the permission:
can('team:edit'), or a class name alone such asTeam::class, where a method expecting a record would be called without one);mandate.gate.defer_to_policies(defaulttrue) 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.phpwith aTeamPolicyfixture: 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 (noarray_any).README documents the behaviour under Laravel Gate Integration and adds the config row.
🤖 Generated with Claude Code