Senior: питання на співбесіді з теми «Контроль доступу»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
У багатоорендному (multi-tenant) застосунку дані різних клієнтів-компаній лежать в одних таблицях з колонкою tenant_id. Найгірший можливий інцидент - витік даних між орендарями, тож ізоляція має бути системною, а не «не забути where в кожному запиті».
Рівень застосунку - глобальна область видимості Eloquent:
#[ScopedBy(TenantScope::class)]
class Invoice extends Model {}
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->qualifyColumn('tenant_id'), Tenant::current()->id);
}
}
Кожен Eloquent-запит до моделі автоматично обмежено поточним орендарем. Плюс автоматичне заповнення tenant_id при створенні.
Слабкі місця глобальних областей:
- не працюють поза Eloquent:
DB::table('invoices'), сирі запити, звіти, деякі пакети; withoutGlobalScopes()- один виклик знімає захист;- черги, команди, планувальник - немає HTTP-запиту, і «поточний орендар» має бути встановлений явно (інакше область або падає, або - гірше - не застосовується);
- зв'язки й підзапити (
whereHas,join) можуть обійти область для пов'язаних таблиць; - унікальні індекси й
exists-валідація безtenant_id- перевірка «email уже зайнятий» по всіх орендарях розкриває існування даних.
Рівень бази - Row-Level Security (RLS) у PostgreSQL:
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::bigint)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::bigint);
DB::statement("SELECT set_config('app.tenant_id', ?, true)", [$tenant->id]); // true - лише в межах транзакції
Тепер будь-який запит - Eloquent, сирий SQL, звіт - бачить лише рядки поточного орендаря. Помилка в коді застосунку не перетвориться на витік.
Підводні камені RLS:
- власник таблиці й суперкористувачі обходять RLS за замовчуванням - потрібен
FORCE ROW LEVEL SECURITYі окрема роль застосунку без прав власника та безBYPASSRLS; - пули з'єднань (PgBouncer у режимі транзакцій, постійні з'єднання Octane): значення, встановлене для сесії, «перетікає» в наступний запит іншого орендаря. Тому -
set_config(..., true)/SET LOCALу транзакції на кожен запит; - міграції, адмінка й фонові задачі між орендарями потребують окремої ролі з явним обходом;
- продуктивність: умова політики додається до кожного запиту - потрібні індекси з
tenant_idпершою колонкою.
Підсумок: глобальна область - зручність і перший рівень, RLS - гарантія на рівні бази. Для даних, де витік між клієнтами неприпустимий, - обидва, плюс тести «орендар A не бачить даних орендаря B» для кожної моделі.
Докладніше в документації: PostgreSQL: Row Security Policies
TOCTOU (time-of-check to time-of-use) - між перевіркою умови й використанням її результату стан змінюється. У вебзастосунку це паралельні запити, кожен з яких проходить перевірку до того, як інший запише зміни.
// вразливо
public function withdraw(Request $request)
{
$account = $request->user()->account;
if ($account->balance >= $request->amount) { // перевірка
$account->decrement('balance', $request->amount); // використання
Payout::create([...]);
}
}
Нападник відправляє 20 однакових запитів одночасно. Усі читають баланс 1000, усі проходять перевірку, і рахунок отримує 20 виплат. Інструменти на кшталт Burp Suite відправляють такі «пачки» з точністю до мілісекунд, тож це не теоретична атака.
Де це трапляється:
- баланс, бонуси, подарункові картки, виведення коштів;
- одноразові купони й промокоди («використати один раз»);
- ліміти («до 3 безкоштовних проєктів», «один відгук на замовлення»);
- голосування й лайки;
- запрошення й одноразові посилання;
- перевірка прав, після якої права відкликано, а дія вже виконується.
Як захищатися:
1. Атомарна умова в самому запиті до бази:
$updated = Account::whereKey($account->id)
->where('balance', '>=', $amount)
->decrement('balance', $amount);
if ($updated === 0) {
throw new InsufficientFunds;
}
Перевірка й зміна - один оператор UPDATE ... WHERE balance >= ?. База гарантує, що паралельні запити не пройдуть обидва.
2. Блокування рядка в транзакції:
DB::transaction(function () use ($accountId, $amount) {
$account = Account::lockForUpdate()->findOrFail($accountId);
// поки транзакція не завершилась, інші lockForUpdate чекають
});
3. Унікальні обмеження - найнадійніше для «один раз»: унікальний індекс на (coupon_id, user_id) - другий запис просто не вставиться, хоч скільки паралельних запитів.
4. Атомарні блокування кешу - Cache::lock("payout:{$userId}", 10)->block(5, ...) для операцій, що зачіпають кілька систем.
5. Ключі ідемпотентності для операцій з грошима - повтор того самого запиту не створює другу операцію.
Чого недостатньо:
- перевірка в застосунку без блокування - вікно гонитви лишається;
- блокування в пам'яті процесу - на кількох серверах чи воркерах не працює;
- «малоймовірно» - нападник спеціально робить це ймовірним.
Тестування: паралельні запити в тестах (Http::pool, Concurrency::run) або навантажувальні інструменти - звичайні послідовні тести таких помилок не бачать.
Помилки авторизації рідко ловлять «звичайні» тести: розробник перевіряє, що власник може виконати дію, а те, що чужий користувач не може, - не перевіряється. Тому авторизацію тестують окремо й системно.
1. Матриця доступу. Для кожного ресурсу - таблиця «роль × дія → очікуваний результат»:
| гість | власник | інший користувач | модератор | адмін іншої організації | |
|---|---|---|---|---|---|
| переглянути | 404 | 200 | 404 | 200 | 404 |
| змінити | 401 | 200 | 403 | 403 | 404 |
| видалити | 401 | 200 | 403 | 200 | 404 |
Матриця - і документація, і основа тестів.
2. Датасети в Pest - один тест на всю матрицю:
dataset('post access', [
'guest' => [fn () => null, 'update', 401],
'owner' => [fn () => Post::first()->author, 'update', 200],
'stranger' => [fn () => User::factory()->create(), 'update', 403],
]);
it('enforces access to posts', function (Closure $actor, string $action, int $status) {
$post = Post::factory()->create();
$user = $actor();
$response = ($user ? $this->actingAs($user) : $this)
->putJson("/api/posts/{$post->id}", ['title' => 'Новий заголовок']);
$response->assertStatus($status);
if ($status !== 200) {
expect($post->fresh()->title)->not->toBe('Новий заголовок'); // стан не змінився
}
})->with('post access');
Перевіряти не лише статус, а й відсутність змін у базі: буває, що відповідь 403, а дія вже виконана.
3. Тест «кожен маршрут захищено». Пройтися по Route::getRoutes() і переконатися, що кожен маршрут має middleware auth чи явно позначений як публічний. Новий маршрут без авторизації ламає тест одразу:
it('protects every route', function () {
$public = ['login', 'register', 'password.request', 'home'];
collect(Route::getRoutes())
->reject(fn ($route) => in_array($route->getName(), $public, true))
->each(fn ($route) => expect($route->gatherMiddleware())->toContain('auth'));
});
4. Тести політик напряму - швидкі модульні тести для складних правил ($user->can('update', $post)), окремо від HTTP.
5. Багатоорендність: для кожної моделі - тест «орендар A не бачить даних орендаря B» на всіх шляхах: список, деталі, пошук, експорт, фільтри.
6. Ручне й автоматизоване тестування на продакшен-подібному середовищі: два облікові записи в браузері (чи розширення на кшталт Autorize для Burp), що повторюють запити одного користувача від імені іншого.
Процес: новий ендпойнт без тестів на заборону не проходить рев'ю коду - це найдешевший спосіб не допустити IDOR.
Звичайні логи застосунку відповідають на питання «що зламалося». Журнал аудиту відповідає на інші: хто, що, коли й до чого отримав доступ чи змінив. Без нього після інциденту неможливо з'ясувати масштаб витоку, а в разі спору - довести, хто виконав дію.
Які події записувати:
- автентифікація: успішні й невдалі входи, вихід, зміна пароля, увімкнення/вимкнення 2FA, скидання пароля;
- зміна прав: призначення ролей, запрошення в команду, видача й відкликання токенів;
- доступ до чутливих даних: перегляд персональних даних, експорт, завантаження документів;
- адміністративні дії: «увійти як користувач», зміна налаштувань безпеки, видалення даних;
- відмови в доступі (403) - серія відмов від одного користувача часто означає спробу перебору ідентифікаторів.
Структура запису:
{
"at": "2026-10-04T10:15:00Z",
"actor": {"type": "user", "id": 42, "impersonated_by": null},
"action": "invoice.downloaded",
"subject": {"type": "invoice", "id": 1041},
"tenant_id": 7,
"ip": "203.0.113.5",
"user_agent": "...",
"request_id": "9f2c...",
"result": "allowed"
}
Вимоги до журналу аудиту:
- незмінність: запис лише додається; адміністратори застосунку не можуть його змінити чи видалити. Окреме сховище чи таблиця з правами лише на вставку, експорт у зовнішню систему (SIEM);
- без секретів: паролі, токени, повні номери карток - ніколи;
- мінімізація персональних даних: записувати ідентифікатори, а не вміст документів; врахувати вимоги GDPR щодо зберігання й видалення;
- узгоджений час (UTC) і ідентифікатор запиту, щоб зв'язати з рештою логів;
- термін зберігання відповідно до регуляторних вимог.
У Laravel:
spatie/laravel-activitylog- журнал змін моделей і власних подій;- події автентифікації (
Login,Failed,Logout,PasswordReset) - слухачі, що пишуть у журнал; Gate::after- зручне місце, щоб фіксувати рішення авторизації для чутливих дій.
Журнал без моніторингу допомагає лише після інциденту. Сповіщення на аномалії - масовий експорт, вхід адміністратора з нової країни, сотні відмов у доступі за хвилину - дають змогу помітити атаку, поки вона триває.
Найдорожчі атаки часто не використовують жодної технічної вразливості: зловмисник просто викликає легітимну функцію у масштабі, на який бізнес не розраховував.
Типові сценарії:
- SMS-накрутка (SMS pumping, toll fraud): бот запитує коди підтвердження на тисячі номерів у дорогих напрямках. Оператор, з яким у змові зловмисник, ділиться з ним виручкою, а застосунок платить за кожне SMS - рахунок на тисячі доларів за ніч;
- промокоди й пробні періоди: тисячі реєстрацій для одноразової знижки чи безкоштовного тарифу;
- реферальні бонуси: ферми акаунтів запрошують одна одну;
- скупка дефіцитного товару ботами, автоматичне бронювання без оплати;
- збір даних: перебір публічних профілів чи цін конкурентами.
Чому звичайне обмеження частоти не допомагає: ліміт «5 запитів на хвилину з IP» обходиться ротацією тисяч IP з резидентних проксі, а ліміт на акаунт - створенням нових акаунтів.
Багаторівневий захист:
| Рівень | Що обмежує |
|---|---|
| IP і підмережа | грубі атаки з одного джерела |
| акаунт | дії одного користувача |
| ціль дії | кількість SMS на номер, на префікс країни, на напрямок |
| пристрій чи сесія | відбиток браузера, токен застосунку |
| глобальний бюджет | загальна кількість SMS на годину для всього сервісу |
RateLimiter::for('sms', function (Request $request) {
$phone = (string) $request->input('phone');
return [
Limit::perHour(3)->by('phone:' . $phone),
Limit::perHour(10)->by('ip:' . $request->ip()),
Limit::perHour(500)->by('sms:global'),
];
});
Що ще працює:
- дозволені країни для SMS - лише ті, де справді є користувачі; дорогі міжнародні напрямки вимкнені за замовчуванням;
- бюджети й сповіщення: ліміт витрат у провайдера SMS, алерт при стрибку кількості відправлень;
- дешевша альтернатива SMS: вхід за посиланням на email, TOTP, passkeys;
- тертя для ризикових дій: CAPTCHA чи Turnstile лише після підозрілих ознак, а не для всіх;
- бізнес-правила: промокод прив'язаний до верифікованої картки чи телефону, реферальний бонус - після першої оплати, а не реєстрації;
- затримка винагороди: бонус нараховується через кілька днів, коли шахрайство встигли виявити.
Моніторинг важливіший за правила: метрики на кожну дорогу дію (SMS, бонуси, пробні періоди) з порогами аномалій. Правила зловмисники обходять, а різкий стрибок графіка видно одразу.
Моделювання загроз для нової функції має включати питання «що буде, якщо цю дію викличуть мільйон разів» - разом з питаннями про доступ і валідацію.
Докладніше в документації: OWASP API6:2023 Unrestricted Access to Sensitive Business Flows