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

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

---

## Проблема

`/api/admin/*` на проде защищён **только** nginx HTTP Basic Auth (`^~ /api/admin`, `.htpasswd-admin`). Прикладной `EnsureSaasAdmin` — **fail-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 off` → `REMOTE_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` (новый)

```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_USER` → **403**;
- `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=production` → `basic_auth_gate` включается сам; дефолт `basic_auth_allowlist=['admin']` совпадает с `.htpasswd-admin`. Менять прод-`.env` не обязательно. Живая дыра закрывается на выкате. Claude прод не катит.

---

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

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