Skip to main content
Laravel, shipping fast.
Chapter 5 · Data You Own

Authorization on a Record

Julian Beaujardin

Chapter 4 said abilities decide what a token may do, and Policies decide which records it may do it to. Consumers are the first resource in this API where the second question is real.

The rule: an operator, a caller whose token has the consumers:manage ability, may do anything. An ordinary consumer may read its own record and nothing else.

Most of that rule is about an ability, and Chapter 4 already has the tool for abilities:

// app/Http/Controllers/ConsumerController.php
#[Middleware('ability:consumers:manage', except: ['show'])]
class ConsumerController
{
    #[Authorize('view', 'consumer')]
    public function show(Consumer $consumer): ConsumerResource
    {
        return ConsumerResource::make($consumer);
    }

    // ... index, store, update, destroy
}

Every action except show needs the operator’s ability, and so will any action added later. Only show asks a question about a record: is this consumer the caller? That question belongs in a Policy.

php artisan make:policy ConsumerPolicy --model=Consumer
// app/Policies/ConsumerPolicy.php
class ConsumerPolicy
{
    public function view(
        Consumer $caller,
        Consumer $consumer,
    ): Response {
        $allowed = $caller->is($consumer)
            || $caller->tokenCan('consumers:manage');

        return $allowed
            ? Response::allow()
            : Response::denyAsNotFound();
    }
}

$caller->is($consumer) is true when both are the same row. When they aren’t, and the caller is no operator, the answer is a 404 and not a 403. A 403 would confirm that consumer 7 exists, and to a caller who has no business knowing, that is already information.

One name needs a word. In a Policy, Response is Illuminate\Auth\Access\Response, the Policy’s answer, and not the HTTP response the word means everywhere else in this book.

Laravel finds the Policy by its name: ConsumerPolicy in app/Policies belongs to Consumer in app/Models, with no registration. In the attribute, the string 'consumer' names the route parameter, so the Policy receives the model that route model binding already loaded. A request that fails is refused before the method runs. (Laravel documentation: Authorization › Creating Policies and Controllers › Authorization Attributes.)

One more line makes the two checks agree about what they give away. Laravel loads the {consumer} record before a route’s own middleware runs. A caller without the ability would therefore get a 404 for a consumer that doesn’t exist and a 403 for one that does, and could map your IDs by the difference. Tell Laravel to check abilities first:

// bootstrap/app.php, inside withMiddleware()
$middleware->prependToPriorityList(
    before: SubstituteBindings::class,
    prepend: CheckForAnyAbility::class,
);

Now a token without the ability gets a 403 whatever the ID, before the record is looked up. If you ever use Sanctum’s other middleware, CheckAbilities, which demands every ability listed, move it the same way. (Laravel documentation: Middleware › Sorting Middleware.)

Why a Policy and not an if in the controller? Because of the next endpoint. When someone adds GET /consumers/{consumer}/usage six months from now, the question “whose record is this?” has one place it is answered, and a test file that already covers it.

The audio could not be loaded. Try again in a moment.