Files
portal/docs/superpowers/specs/2026-06-21-m1-admin-gate-fail-closed-design.md
T

7.5 KiB
Raw Blame History

M-1 — fail-closed app-гейт админ-зоны (REMOTE_USER + config-allowlist)

Дата: 21.06.2026. Кодовая фраза стены: «роутер-наставник». Статус: дизайн одобрен владельцем. Следующий шаг — план (writing-plans) → TDD. Связь: приёмка раунд-2, находка M-1 (owner-decisions) = F-T2 доменного аудита 15.06.


Проблема

/api/admin/* на проде защищён только nginx HTTP Basic Auth (^~ /api/admin, .htpasswd-admin). Прикладной EnsureSaasAdminfail-open (стопгэп 25.05: прежний fail-closed 503 снят, т.к. настоящего saas-admin SSO ещё нет).

Живая дыра (доказана curl'ом на боевом 21.06): location ^~ /api/admin ловит только пути, буквально начинающиеся с /api/admin. Обход через front-controller:

Проба Путь Код Итог
прямой /api/admin/tenants 401 nginx-пароль работает
обход 1 /index.php/api/admin/tenants 200 пароль обойдён, слив данных тенантов (баланс, email)
double-slash /api//admin/tenants 401 merge_slashes нормализует
обход 2 /API/admin/tenants 200 регистр — второй обход

Запросы-обходы попадают в location / (где auth_basic off) и через try_files .../index.php доходят до Laravel с PATH_INFO; снипет try_files $fastcgi_script_name =404 проверяет существование /index.php (а он есть) и PATH_INFO-обход не режет. EnsureSaasAdmin fail-open → Laravel молча отдаёт админ-данные. По POST те же /api/admin/* (правка баланса, refund, перезапись webhook-секрета, старт impersonation, 152-ФЗ-erasure) лежат на том же необходимом пути.

Вывод: защита только на nginx принципиально обходится. Нужен app-слойный fail-closed гейт.


Решение

Опора на факт: nginx пробрасывает REMOTE_USER в PHP (fastcgi_param REMOTE_USER $remote_user в /etc/nginx/fastcgi.conf и fastcgi_params). REMOTE_USER непуст только у запросов, прошедших admin-basic-auth (единственные auth_basic on-локации — ^~ /admin и ^~ /api/admin); все остальные локации — auth_basic off$remote_user пуст.

EnsureSaasAdmin начинает требовать непустой REMOTE_USER из allowlist:

  • Легитимный /api/admin/* → nginx basic-auth → REMOTE_USER=admin ∈ allowlist → пропуск.
  • Оба обхода → location / auth_basic offREMOTE_USER пуст → fail-close 403.

Невозможность спуфинга: на auth_basic off-локациях $remote_user всегда пуст; присланный клиентом заголовок REMOTE-USER становится HTTP_REMOTE_USER, а не REMOTE_USER (последний жёстко ставит nginx из $remote_user).

Решение владельца: строгость A2 — allowlist; источник — config/env (не БД, не миграция).


Компоненты

1. config/admin.php (новый)

return [
    // Логины nginx HTTP Basic Auth (.htpasswd-admin), допущенные в saas-admin зону.
    // CSV из env; дефолт совпадает с прод-.htpasswd (единственный логин — admin).
    'basic_auth_allowlist' => array_values(array_filter(array_map(
        'trim',
        explode(',', (string) env('ADMIN_ALLOWED_USERS', 'admin')),
    ))),

    // Включение fail-closed гейта. В local/testing — выкл (nginx нет, тесты
    // аутентифицируются иначе); на проде/staging — вкл. Только env(): config
    // грузится до готовности контейнера, app()->environment() здесь падает
    // («Class "env" does not exist»).
    'basic_auth_gate' => (bool) env(
        'ADMIN_GATE_ENFORCED',
        ! in_array(env('APP_ENV', 'production'), ['local', 'testing'], true),
    ),
];

2. EnsureSaasAdmin::handle() (правка)

Порядок:

  1. Существующий блок G7-B (impersonation marker / lpimp_ Bearer → abort(403)) — без изменений, остаётся первым.
  2. Новый fail-closed: если config('admin.basic_auth_gate') и (REMOTE_USER пуст или ! in_array(REMOTE_USER, config('admin.basic_auth_allowlist'), true)) → abort(403, '…').
  3. Иначе → $next($request).

REMOTE_USER читать как (string) $request->server('REMOTE_USER', '').

Докблок класса обновить: nginx-дверь дополнена app-слойным fail-closed гейтом по REMOTE_USER; убрать формулировку «middleware больше не закрывает зону».

3. Тест tests/Feature/Admin/EnsureSaasAdminGateTest.php (новый, TDD)

При config(['admin.basic_auth_gate' => true]), GET на admin-роут:

  • без REMOTE_USER403;
  • withServerVariables(['REMOTE_USER' => 'admin']) (allowlist) → не 403 (гейт пройден);
  • withServerVariables(['REMOTE_USER' => 'eve']) (не в списке) → 403;
  • доп.: при basic_auth_gate=false (дефолт testing) без REMOTE_USERне 403 (регрессия: существующие admin-тесты не ломаются).

Allowlist в тесте фиксировать через config(['admin.basic_auth_allowlist' => ['admin']]).


Границы / что НЕ делаем (YAGNI)

  • Без изменений схемы/БД — allowlist в конфиге, не в saas_admin_users.
  • Без нового SSO/логина — Yandex 360 SSO остаётся отложенным (Б-1/DO-4); это отдельная будущая фича, не отменяется.
  • nginx не трогаем в рамках M-1 — app-гейт самодостаточен и не зависит от точности location-regex. (Опционально владелец может позже добить nginx как defense-in-depth — вне scope.)
  • Статус-код — 403 (не прежний 503-стопгэп).

Деплой (шаг владельца, не Claude)

После влития: на проде APP_ENV=productionbasic_auth_gate включается сам; дефолт basic_auth_allowlist=['admin'] совпадает с .htpasswd-admin. Менять прод-.env не обязательно. Живая дыра закрывается на выкате. Claude прод не катит.


Тест-план (verification)

  • Профильный Pest-сьют EnsureSaasAdminGateTest — зелёный.
  • Регрессия существующих tests/Feature/Admin/* — без падений (гейт выключен в testing).
  • Pint + Larastan (level 5) = 0 по изменённым файлам.