Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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) або навантажувальні інструменти - звичайні послідовні тести таких помилок не бачать.

Докладніше в документації: PortSwigger: гонитва запитів

Помилки авторизації рідко ловлять «звичайні» тести: розробник перевіряє, що власник може виконати дію, а те, що чужий користувач не може, - не перевіряється. Тому авторизацію тестують окремо й системно.

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.

Докладніше в документації: OWASP Web Security Testing Guide

Звичайні логи застосунку відповідають на питання «що зламалося». Журнал аудиту відповідає на інші: хто, що, коли й до чого отримав доступ чи змінив. Без нього після інциденту неможливо з'ясувати масштаб витоку, а в разі спору - довести, хто виконав дію.

Які події записувати:

  • автентифікація: успішні й невдалі входи, вихід, зміна пароля, увімкнення/вимкнення 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 - зручне місце, щоб фіксувати рішення авторизації для чутливих дій.

Журнал без моніторингу допомагає лише після інциденту. Сповіщення на аномалії - масовий експорт, вхід адміністратора з нової країни, сотні відмов у доступі за хвилину - дають змогу помітити атаку, поки вона триває.

Докладніше в документації: OWASP: журналювання

Найдорожчі атаки часто не використовують жодної технічної вразливості: зловмисник просто викликає легітимну функцію у масштабі, на який бізнес не розраховував.

Типові сценарії:

  • 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