Критична помилка в адмінках — думати, що “доступ перевірив middleware, значить все ок”. Middleware зазвичай вирішує лише “чи можна зайти в модуль”. Але витоки і обходи трапляються в двох місцях: коли користувач бачить зайві записи в списках, і коли він може звернутися до конкретного запису напряму по URL. Тому доступ треба обмежувати одразу на двох рівнях: у контролері (авторизація дії) і в запитах (обмеження вибірки даних).
Почнемо з контролера. Контролер — це точка, де ви гарантовано бачите намір: “переглянути список”, “відкрити”, “створити”, “оновити”, “видалити”, “змінити статус”. Тут ви повинні робити явну авторизацію через policy/gate, а не через умовні if. Це дає стандартну поведінку (403), централізацію правил і менше шансів забути перевірку. Практично: у index викликається authorize('viewAny', Model::class), у show/edit/update/destroy — authorize('view', $model) / authorize('update', $model) / authorize('delete', $model). Якщо у вас є кастомні дії (approve/publish), для них мають бути окремі policy-методи і окремі authorize-виклики.
Дуже важливо: authorize має стояти ДО будь-яких побічних дій. Тобто спочатку ви перевіряєте право, а вже потім робите апдейт, відправляєте job, пишете activity. Інакше ви можете випадково виконати частину сценарію до того, як повернете 403, і отримаєте “напівдію” від користувача без прав.
Тепер головне — обмеження доступу в запитах. Навіть якщо ви правильно робите authorize на конкретному записі, ви можете вже “злити” зайві дані через список. Наприклад, користувач не повинен бачити чужі чернетки, але ваш index робить News::latest()->paginate(). У UI це буде видно, навіть якщо редагувати він не може. Це порушення доступу. Тому список має будуватися з урахуванням того, хто дивиться: author бачить свої записи, редактор — записи свого ресурсу, адміністратор — все.
Правило номер один: доступ обмежується в SQL, не в Blade. Тобто ви не робите “витягнути все, а потім в шаблоні сховати”. Це і небезпечно, і неефективно. Ви одразу в запиті додаєте where/join/whereIn, щоб недоступні записи фізично не потрапили в результат.
Є два надійні способи організувати це. Перший — scope на моделі (наприклад, scopeVisibleTo($query, $user)), який додає потрібні where залежно від ролі. Другий — окремий query builder/сервіс для списків, який будує запит з фільтрами і доступами. Обидва варіанти хороші. Головне — щоб це було в одному місці, а не по всіх контролерах.
Далі — захист від прямого доступу по ID. Класика: користувач бачить свій запис /admin/news/123/edit, і пробує /admin/news/124/edit. Якщо ви просто робите News::findOrFail(124) і віддаєте форму — це злам. Якщо ви робите authorize/update — ви отримаєте 403 і формально “захищено”. Але краще зробити ще міцніше: щоб він навіть не міг “знайти” запис, який йому недоступний. Для цього у show/edit ви піднімаєте запис не “в лоб”, а через той самий доступний scope: тобто “знайди запис серед доступних”. Тоді він отримає 404, і це часто правильніше з точки зору безпеки (не розкривати факт існування запису).
Практичний стандарт такий: для модулів з високою чутливістю (фінанси, договори, модерація) — краще “404 для недоступного”, тобто fetch через доступний набір. Для менш критичних — може бути і 403, але у вас уже видно, що доступи критичні, тому краще тягнути через доступний набір і мати однакову поведінку.
Окрема тема — фільтри і query string. Користувач може підставити ?user_id=999 або ?resource_id=… і спробувати “вибрати” чужі дані. Ваші фільтри не повинні розширювати доступ, вони можуть лише звужувати його. Це ключове правило. Тобто спочатку ви накладаєте базове обмеження видимості (хто що може бачити), а потім застосовуєте фільтри користувача. Якщо зробити навпаки або не мати базового обмеження, фільтр може стати інструментом витоку.
Ще одна критична точка — eager loading і пов’язані дані. Навіть якщо основний запис доступний, пов’язані дані можуть бути чутливими. Наприклад, новина доступна, але її activity log або фінансові поля — ні. Якщо ви робите with('activity') без обмежень, ви можете віддати зайве. Тому або не підвантажуйте чутливі зв’язки без потреби, або підвантажуйте їх через окремі authorize-перевірки/окремі endpoints, або робіть окремі ресурси/DTO для “публічного” і “адмінського” представлення.
На рівні контролера також важливо не довіряти полям, які приходять з форми, навіть після authorize. Наприклад, користувач має право редагувати текст, але не має права змінювати approval_status. Якщо він підставить це поле в request, а ви зробите $model->update($request->validated()), ви можете змінити заборонене поле. Тому обмеження доступу до полів робиться у сервісі: whitelist ключів залежно від прав. Це частина “обмеження доступу в запитах”, тільки на запис.
Стандартний “залізобетонний” ланцюжок виглядає так. Маршрут захищений middleware permissions, щоб випадкові не заходили. У контролері ви робите authorize на конкретну дію. Запит на читання будується через доступний набір (scope/query builder), щоб недоступне навіть не попадало у вибірку. А при записі сервіс формує ефективні дані (whitelist) і робить транзакцію для багатотабличних сценаріїв. Тоді “обхід” через URL, через query string і через підсунуті поля не працює.
Висновок: обмеження доступу — це не один if і не один middleware. Це система: авторизація дії в контролері плюс обмеження вибірки в SQL плюс обмеження полів при записі. Якщо ви вибудуєте це як стандарт, у вас не буде ситуацій “у цьому місці захищено, а в іншому забули”, і ви зможете додавати нові екрани та фільтри без страху, що вони випадково відкриють дані або дозволять небезпечні зміни.