Senior: питання на співбесіді з теми «Авторизація»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
3 питання
Починається все з простого $user->is_admin, далі зʼявляється редактор, потім модератор - і умови розповзаються по коду.
Крок перший - роль як enum:
enum Role: string
{
case Admin = 'admin';
case Editor = 'editor';
case Author = 'author';
}
Це вже краще за рядки, але перевірки виду $user->role === Role::Editor розкидані по контролерах ламаються, щойно права ролі змінюються.
Крок другий - права, а не ролі. Код питає «чи можна публікувати», а не «чи ти редактор»:
Gate::define('publish', fn (User $user) => $user->hasPermission('publish'));
Роль стає лише набором прав, і зміна набору не потребує правок у коді.
Policy для дій над моделлю:
class PostPolicy
{
public function update(User $user, Post $post): bool
{
return $user->hasPermission('posts.update')
|| $post->author_id === $user->id;
}
}
before() для суперкористувача - щоб не дублювати перевірку в кожному методі:
public function before(User $user): ?bool
{
return $user->isAdmin() ? true : null;
}
Повертати треба саме null, а не false: false заборонить дію остаточно й не дасть іншим методам відпрацювати.
Коли брати пакет. spatie/laravel-permission дає ролі, права й кеш перевірок з коробки. Він доречний, коли набір прав змінюють з адмінки; якщо ролей три й вони зашиті в код, enum із Policy простіший.
Що не забути: перевірки прав кешуються не самі - на кожен запит це кілька звернень до бази, тому права користувача варто завантажувати разом із ним.
Політика відповідає на питання про один об'єкт: «чи може цей користувач переглянути цей рахунок?». Але дані витікають і там, де об'єкти беруться списком чи через вкладені маршрути, - і туди політика не дотягується, якщо її не викликали.
Три місця, де це трапляється:
1. Списки. Політика view не фільтрує Invoice::paginate(). Якщо запит не обмежено, сторінка покаже всі рахунки:
$invoices = $request->user()->invoices()->latest()->paginate();
Запит має починатися від користувача (чи від команди / тенанта), а не від моделі.
2. Вкладені маршрути. /users/{user}/posts/{post} без обмеження знайде пост 99, навіть якщо він належить іншому користувачу:
Route::get('/users/{user}/posts/{post}', ...)->scopeBindings();
scopeBindings() шукає пост через $user->posts(), а не по всій таблиці.
3. Пошук, експорт, API, черги. Будь-який код, що збирає дані поза звичайним контролером, - звіт, CSV, ендпойнт пошуку, - теж має обмежувати запит.
Як зробити це системно: глобальний скоп на модель для мультитенантності (з обережністю до withoutGlobalScopes()), репозиторій чи запит, що завжди починається від власника, і тест «користувач A не бачить даних користувача B» для кожного ендпойнта зі списком. UUID замість числових ID від цього не рятують - вони лише ускладнюють перебір.
Права ламаються тихо: забута перевірка не дає помилки, вона просто дозволяє зайве. Тому їх тестують на двох рівнях.
1. Правило - напряму, через can():
it('lets only the author edit a draft', function () {
$author = User::factory()->create();
$post = Post::factory()->draft()->for($author)->create();
expect($author->can('update', $post))->toBeTrue()
->and(User::factory()->create()->can('update', $post))->toBeFalse();
});
can() проходить увесь шлях: before, саму політику, after. Тест швидкий і точно вказує, яке правило зламалося.
2. Ендпойнт - що перевірку справді викликають:
it('forbids editing someone else\'s post', function () {
$post = Post::factory()->create();
$this->actingAs(User::factory()->create())
->put("/posts/{$post->id}", ['title' => 'Чуже'])
->assertForbidden();
expect($post->fresh()->title)->not->toBe('Чуже');
});
Ідеальна політика нічого не варта, якщо контролер її не викликав - цей тест ловить саме таке.
Що варто покривати обов'язково:
- «користувач A не бачить і не змінює даних користувача B» для кожного ресурсу;
- гість, звичайний користувач, власник, адмін - матриця ролей для ключових дій;
- списки й експорт, а не лише окремі сторінки;
denyAsNotFound()- що повертається саме 404.
Архітектурний тест на кшталт «кожен метод контролера, що змінює дані, викликає Gate::authorize» складно написати надійно, тому покладаються на тести ендпойнтів.
Докладніше в документації: Авторизація через модель користувача