Authorization

Policies

How Laravel policies guard each endpoint, each resource a request reaches and each row it writes, before validation and before any query.

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 methodChecked by
viewAnyevery endpoint of the resource, and every resource that a payload reaches
viewthe targets of attach, detach, sync and toggle, and the view gate
createmutate creations: once per resource class, then once per created row
updatemutate updates, with the changes of the entry, and links checked on the parent row when the relation declares no ability
delete, restore, forceDeletedestroy, restore, forceDestroy
the abilities that a relation declaresthe 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, restore and forceDestroy check viewAny on 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 as author.last_name, and the response block of a mutation.
  • Each create in mutate, at the root or under a relation, needs create on 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 viewAny in the list to add an ability: a resource that returns ['export'] for Endpoint::Search no longer checks viewAny there.
  • Endpoint::Operate and Endpoint::Metric are the exception: their list adds to viewAny.
  • 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, a detach, a sync or a toggle without attributes, is checked with view.
  • A row that an entry moves under a HasOne, HasOneOfMany, HasMany, MorphOne, MorphOneOfMany or MorphMany relation is checked with update and its new keys.
  • The policies see the rows as they were before the request.
  • destroy, restore and forceDestroy check delete, restore or forceDelete on every row before they write any row.

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 KeysOutOfReach event before its 422, so that a listener can record it.
  • php artisan rest:check reports each resource that the routes reach without its own perimeterQuery(), and each action on rows without authorizeEach(). Mark a resource whose rows are meant for every user who passes viewAny, 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."}
  ]
}
  • path is empty for the ability of the endpoint, such as viewAny on a search.
  • key appears for a check on a row: {"path": "mutate.0", "ability": "update", "key": 2, "message": "Closed tickets are read-only."}.
  • A 404 stays 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. GateEvaluated listeners see one evaluation per distinct check.
  • Policies run with Gate::forUser($request->user()). Gate::before() and Gate::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;
}
Write the policies instead whenever you can: the package expects every policy to run, viewAny included.

To keep decisions between requests, see Decision cache.