Policies
Every endpoint checks the Laravel policy of the models it touches. The checks run in two stages: the abilities of the endpoint and of every resource that the payload reaches, before validation; then the abilities of each row, before the first write.
Define a policy for every model you expose. php artisan rest:policy PostPolicy --model=Post writes one that allows every ability to a signed-in user: edit it before production. See Artisan commands.
| Policy method | Checked by |
|---|---|
viewAny | every endpoint of the resource, and every resource that a payload reaches |
view | the targets of attach, detach, sync and toggle, and the view gate |
create | mutate creations: once per resource class, then once per created row |
update | mutate updates, with the changes of the entry, and links checked on the parent row when the relation declares no ability |
delete, restore, forceDelete | destroy, restore, forceDestroy |
| the abilities that a relation declares | the links of that relation |
Checks before validation
Each request checks the abilities of its endpoint, then the resources that its payload reaches, before it validates the payload and before any query. A denied check answers with the status of the policy's response, 403 by default, even when the payload is invalid: the 403 comes before the 422, also for Precognition requests.
details,search,mutate,destroy,restoreandforceDestroycheckviewAnyon their resource. Actions and metrics add their own abilities, see Actions.- Every resource that the payload reaches needs
viewAny: each level of an include, of an aggregate and of a relation filter, the relation of a sort such asauthor.last_name, and theresponseblock of a mutation. - Each
createinmutate, at the root or under a relation, needscreateon the created resource, checked once per resource class with the model class. - The
authorize()method of a request still runs first.
Changing the abilities of an endpoint
Override abilities() on the resource. The list replaces the default of the endpoint:
use Lomkit\Rest\Http\Routing\Endpoint;
public function abilities(Endpoint $endpoint): array
{
return $endpoint === Endpoint::Destroy ? ['viewAny', 'deleteAny'] : parent::abilities($endpoint);
}
- Keep
viewAnyin the list to add an ability: a resource that returns['export']forEndpoint::Searchno longer checksviewAnythere. Endpoint::OperateandEndpoint::Metricare the exception: their list adds toviewAny.- To let users write a resource that they cannot list, return
[]for the write endpoints.
Row checks
mutate checks every row of the request in one batch, inside its transaction, before the first write: a denied row writes nothing, and no hook, event or model observer runs.
Each created row is checked with create and its draft: an unsaved model filled with the validated attributes of its entry and with the keys that its relations set. Declare the draft as an optional parameter:
public function create(User $user, ?Post $draft = null): bool
{
return $draft === null || $draft->team_id === $user->team_id;
}
Each updated row is checked with update, the stored row and a Lomkit\Rest\Authorization\Changes object of the values that the write sets. has($key), get($key) and keys() read it:
use Illuminate\Auth\Access\Response;
use Lomkit\Rest\Authorization\Changes;
public function update(User $user, Post $post, ?Changes $changes = null): Response
{
return $changes?->get('team_id') !== null && $changes->get('team_id') !== $user->team_id
? Response::deny('Posts cannot move to another team.')
: Response::allow();
}
- A row that an entry names without writing it, the target of an
attach, adetach, asyncor atogglewithout attributes, is checked withview. - A row that an entry moves under a
HasOne,HasOneOfMany,HasMany,MorphOne,MorphOneOfManyorMorphManyrelation is checked withupdateand its new keys. - The policies see the rows as they were before the request.
destroy,restoreandforceDestroycheckdelete,restoreorforceDeleteon every row before they write any row.
Relation links
A relation declares the abilities that check its links, on the policy of the parent row, with the parent and the related row:
BelongsToMany::make('tags', TagResource::class)
->abilities(attach: 'attachTag', detach: 'detachTag'),
public function attachTag(User $user, Post $post, Tag $tag): bool
{
return $user->team_id === $post->team_id;
}
Without a declared ability, the link is checked with update on the parent row. A declared ability that no policy method answers is denied. See Relations.
The perimeter and the policies
perimeterQuery() decides which rows exist for the client: a key outside it is a 422, as if the row did not exist. A policy decides what the client may do with a row it can see: a refused row is a 403. Use the perimeter for tenants and visibility, and the policies for rights.
- A request that names keys outside the perimeter dispatches the
KeysOutOfReachevent before its422, so that a listener can record it. php artisan rest:checkreports each resource that the routes reach without its ownperimeterQuery(), and each action on rows withoutauthorizeEach(). Mark a resource whose rows are meant for every user who passesviewAny, such as countries, with#[Lomkit\Rest\Attributes\WithoutPerimeter].
Denials
A denied check throws Lomkit\Rest\Exceptions\AuthorizationDenied, which extends Laravel's AuthorizationException. It holds every denial of the batch, at most 50, in $exception->denials: the path in the payload, the ability, the resource class, the key of the row and the message.
The response takes the message and the status of the first denial, in the order of the request, so Response::denyAsNotFound() still answers 404. The body holds the message only:
{"message": "Teams are not listed."}
A denial without a message gives This action is unauthorized., as in Laravel. To add the denials to the body, register the renderer in bootstrap/app.php:
use Lomkit\Rest\Exceptions\DenialsRenderer;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(new DenialsRenderer);
})
The body then lists every denial. For a search whose aggregate and include reach two resources that the user cannot list:
{
"message": "Tags are not listed.",
"denials": [
{"path": "search.aggregates.0", "ability": "viewAny", "message": "Tags are not listed."},
{"path": "search.includes.0", "ability": "viewAny", "message": "Teams are not listed."}
]
}
pathis empty for the ability of the endpoint, such asviewAnyon a search.keyappears for a check on a row:{"path": "mutate.0", "ability": "update", "key": 2, "message": "Closed tickets are read-only."}.- A
404stays unchanged.
Strict mode
The queries check viewAny again on each resource they search, and reuse the decisions made before validation. With authorizations.strict set to true, a check that the request did not make before validation throws Lomkit\Rest\Exceptions\UnplannedCheckException instead. Turn it on in your test suite to find a resource that a request reaches without a check:
<env name="REST_AUTHORIZATIONS_STRICT" value="true"/>
Within a request
- Each distinct check runs once per request: the policy runs on the first check, and the later checks reuse its decision.
GateEvaluatedlisteners see one evaluation per distinct check. - Policies run with
Gate::forUser($request->user()).Gate::before()andGate::after()callbacks receive that user. - After each write, the package drops the decisions on the written model. A hook of yours that writes a model calls
$this->request()->context()->authorizer()->forget($model)after the write. - An override of
authorizedTo()on the resource applies to every check: the endpoint abilities, the reached resources, the row checks and the gates.
Turning the policies off
Override isAuthorizingEnabled() on one resource, or on the base resource for every resource, and return false. Every check then passes.
public function isAuthorizingEnabled(): bool
{
return false;
}
viewAny included.To keep decisions between requests, see Decision cache.