Перейти к содержанию

Policy и право на конкретную заявку

Оглавление серии «Laravel 13 и PHP: портал «Заявка»»

Личный список уже фильтруется по текущему пользователю. Но человек может набрать чужой номер заявки непосредственно в адресе. Добавим TicketPolicy и подробную карточку. Результат урока — проверка права на объект после его получения, одинаково применимая будущим формам и API. Скрытая ссылка в списке не является защитой: каждый путь чтения должен иметь собственную серверную проверку.

Будем использовать простое правило владельца. Пользователь читает свои заявки и может редактировать только открытую. Администраторов, менеджеров других отделов и участников организации здесь нет. Если такая роль потребуется, правило нужно расширить явно и проверить по источнику полномочий. Нельзя автоматически считать любого вошедшего сотрудника управляющим всем порталом.

Политика объекта

Создадим полный app/Policies/TicketPolicy.php. Laravel обнаруживает TicketPolicy по соглашению имён модели и папки политики в стандартном каркасе. Проверку выполняем через Gate, поэтому контроллер не зависит от наличия AuthorizesRequests trait в базовом Controller.

<?php
namespace App\Policies;
use App\Models\Ticket;
use App\Models\User;
class TicketPolicy
{
    public function view(User $user, Ticket $ticket): bool { return $ticket->user_id === $user->id; }
    public function create(User $user): bool { return true; }
    public function update(User $user, Ticket $ticket): bool
    {
        return $this->view($user, $ticket) && $ticket->status === 'open';
    }
    public function close(User $user, Ticket $ticket): bool { return $this->view($user, $ticket); }
}

Руководство авторизации Laravel объясняет policies, Gate и обнаружение. Метод create принимает только пользователя, потому что конкретной заявки ещё нет. Метод view получает уже существующую запись. update дополняет проверку владельца состоянием, а close пока сохраняет право владельца даже для повторного закрытия: будущая операция сделает повтор безопасным без новой истории.

Создадим полный app/Http/Controllers/TicketShowController.php. Неявное связывание маршрута получает Ticket по id, а Gate проверяет право перед передачей полей в шаблон.

<?php
namespace App\Http\Controllers;
use App\Models\Ticket;
use Illuminate\Support\Facades\Gate;
class TicketShowController extends Controller
{
    public function __invoke(Ticket $ticket)
    {
        Gate::authorize('view', $ticket);
        return view('tickets.show', compact('ticket'));
    }
}

Ниже полный resources/views/tickets/show.blade.php. Данные не появляются в HTML до успешной проверки контроллера.

@extends('layouts.app')
@section('content')
<h1>{{ $ticket->title }}</h1>
<p>{{ $ticket->body }}</p>
<p>Статус: {{ $ticket->status === 'open' ? 'Открыта' : 'Закрыта' }}</p>
<a href="{{ route('tickets.index') }}">К списку</a>
@endsection

В routes/web.php добавьте после списка следующий маршрут и импорт TicketShowController. Полный итоговый routes этого шага присутствует в архиве; фрагмент показывает только новое связывание.

Route::get('/tickets/{ticket}', TicketShowController::class)
    ->whereNumber('ticket')->middleware('auth')->name('tickets.show');

В index.blade.php замените strong заголовка ссылкой ниже. Все остальные части списка сохраняются, полный итоговый шаблон также включён в cumulative-состояние.

<a href="{{ route('tickets.show', $ticket) }}">{{ $ticket->title }}</a>

Получение записи и право чтения

Если записи нет, binding завершит запрос отсутствием объекта. Если запись существует, но принадлежит другому пользователю, Gate отклонит доступ. В выбранном варианте это обычно различимые 404 и 403. Для продукта с требованием скрыть сам факт существования можно выбрать отказ как not found через отдельное решение политики. Мы не обещаем скрытие существования, потому что текущая реализация явно различает эти обстоятельства.

whereNumber ограничивает формат сегмента и сохраняет будущий путь /tickets/create от случайного попадания в карточку. Это проверка маршрута, не авторизация. Номер может быть правильным и относиться к чужой записи, а пользователь может быть правильным и запросить отсутствующий номер. Разделение причин полезно для поведения портала и для анализа журналов.

Полезный неправильный случай — проверить @can только в Blade, оставив контроллер без Gate. Кнопка или ссылка исчезнет, но прямо набранный адрес всё равно сможет передать body в HTML. Поэтому серверная проверка должна предшествовать выдаче содержимого. Директива @can пригодится для доступных действий интерфейса, однако не заменит проверку обработчика этих действий.

Сравнение user_id и id использует ожидаемый тип полей Eloquent. Для стандартного числового ключа нашего SQLite-профиля оба идентификатора представлены согласованно. Если приложение переходит к UUID или другому типу, следует пересмотреть схему и casts, а не чинить авторизацию нестрогим сравнением всех возможных значений. Право владельца зависит от точного совпадения личности, поэтому ясный тип является частью модели.

Право зависит от состояния

Открытая заявка владельца проходит update, закрытая не проходит. Это правило будет использовать форма редактирования. При этом проверка перед записью не блокирует конкурентное изменение автоматически. Если другая операция закрывает заявку между проверкой и сохранением, обработчик должен повторно выразить необходимое условие при записи. Policy отвечает за право текущего снимка, а транзакция или условное обновление защищает изменение базы.

Метод close допускает владельца закрытой заявки, чтобы повтор команды мог вернуть понятный успешный результат без повторной истории. Это не разрешение переоткрыть её. Переход состояния определит отдельный контроллер с условием open. Важно не делать один широкорассуждающий метод manage, который затем случайно используется для всех действий. Названия policy должны соответствовать конкретным операциям кабинета.

create пока возвращает true для вошедшего пользователя. Auth middleware обеспечивает, что метод не получает гостя этого маршрута. Если позже понадобится лимит заявок или право участника организации, это станет отдельным условием создания. Не делайте проверку исключительно на клиенте по скрытию формы: посторонний HTTP-запрос способен обратиться непосредственно к POST.

Поведение карточки без выдуманного результата

Ожидаемый владелец видит название, текст и статус собственной заявки. Другой подготовленный пользователь, если читатель добавит его в своей учебной базе, получает отказ при прямом адресе. В этой работе такие аккаунты и запросы не создавались. Полезно записать ожидаемые причины заранее: политику можно прочитать и оценить, но реальный результат среды ещё требует самостоятельной проверки.

Перед следующим шагом проследите два независимых пути защиты: where user_id в списке и Gate view в карточке. Они соответствуют разным запросам и оба нужны. Затем разберите создание, для которого записи ещё нет: проверяется право пользователя на действие, вход ограничивается Form Request, а владелец назначается сервером. Следующий урок реализует именно этот путь, сохранив существующие методы policy.

Одна политика в разных обработчиках

Gate позволяет использовать то же правило view в карточке, скачивании PDF и JSON. Такая общность полезна, потому что смысл объекта не зависит от формата выдачи. Однако вызов policy не происходит просто из-за существования класса в папке: каждый обработчик должен явно обращаться к нужной способности или использовать подходящее middleware. Наличие TicketPolicy в архиве ещё не доказывает, что все будущие маршруты защищены.

Список ограничен SQL, поэтому чужие строки вообще не формируют его HTML. Карточка получает одну связанную запись и затем проверяет её. Это разные способы выражения той же границы владельца. Для большого списка не следует вычитывать всё и вызывать policy только при отрисовке каждой строки: ненужные данные уже оказались бы в памяти запроса, а количество страниц могло бы раскрывать чужой набор.

Для самостоятельного воспроизведения результата «Ограничить карточку и изменение владельцем» выберите lesson-06 в архиве исходников. Статус выполнения этого состояния: not_run.