# M-1 Admin Gate Fail-Closed Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Закрыть живую дыру обхода админ-пароля (`/index.php/api/admin`, `/API/admin`) app-слойным fail-closed гейтом по `REMOTE_USER` + config-allowlist.

**Architecture:** `EnsureSaasAdmin` (alias `saas-admin`) дополняется fail-closed проверкой: при включённом гейте требует непустой `REMOTE_USER` (проброшен nginx из admin-basic-auth) в allowlist из `config/admin.php`. Обходы попадают в `auth_basic off`-локации → `REMOTE_USER` пуст → 403. Без изменений схемы/БД.

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4. Spec: [2026-06-21-m1-admin-gate-fail-closed-design.md](../specs/2026-06-21-m1-admin-gate-fail-closed-design.md).

---

### Task 1: config/admin.php + fail-closed гейт в EnsureSaasAdmin (TDD)

**Files:**
- Create: `app/config/admin.php`
- Modify: `app/app/Http/Middleware/EnsureSaasAdmin.php`
- Test: `app/tests/Feature/Admin/EnsureSaasAdminGateTest.php` (create)

Контекст: alias `saas-admin` → `EnsureSaasAdmin` (`app/bootstrap/app.php:28`). Тест-роут `/api/admin/tenants` (`AdminTenantsController@index`) — гейт срабатывает ДО контроллера; на pass-through с пустой БД возвращает `{"tenants":[]}` 200. `REMOTE_USER` в тесте ставится `withServerVariables()`.

- [ ] **Step 1: Написать падающий тест**

`app/tests/Feature/Admin/EnsureSaasAdminGateTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;

uses(DatabaseTransactions::class);

/**
 * M-1 (приёмка 21.06): fail-closed гейт админ-зоны по REMOTE_USER + allowlist.
 * Закрывает обходы /index.php/api/admin и /API/admin (front-controller PATH_INFO),
 * где nginx basic-auth не применяется и REMOTE_USER пуст.
 */
beforeEach(function () {
    config(['admin.basic_auth_gate' => true]);
    config(['admin.basic_auth_allowlist' => ['admin']]);
});

test('гейт включён + нет REMOTE_USER → 403', function () {
    $this->getJson('/api/admin/tenants')->assertStatus(403);
});

test('гейт включён + REMOTE_USER в allowlist → пройден (не 403)', function () {
    $this->withServerVariables(['REMOTE_USER' => 'admin'])
        ->getJson('/api/admin/tenants')
        ->assertOk();
});

test('гейт включён + REMOTE_USER не в allowlist → 403', function () {
    $this->withServerVariables(['REMOTE_USER' => 'eve'])
        ->getJson('/api/admin/tenants')
        ->assertStatus(403);
});

test('гейт выключен (дефолт testing) + нет REMOTE_USER → не 403 (регрессия)', function () {
    config(['admin.basic_auth_gate' => false]);
    $this->getJson('/api/admin/tenants')->assertOk();
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && php artisan test tests/Feature/Admin/EnsureSaasAdminGateTest.php`
Expected: FAIL — первые два теста с гейтом ждут 403, но pass-through возвращает 200 («Expected 403 but received 200»).

- [ ] **Step 3: Создать `app/config/admin.php`**

```php
<?php

declare(strict_types=1);

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() здесь падает.
    'basic_auth_gate' => (bool) env(
        'ADMIN_GATE_ENFORCED',
        ! in_array(env('APP_ENV', 'production'), ['local', 'testing'], true),
    ),
];
```

- [ ] **Step 4: Добавить fail-closed в `EnsureSaasAdmin::handle()`**

После существующего блока impersonation (строки 37-43), перед `return $next($request);`, вставить:

```php
        // M-1 (приёмка 21.06): fail-closed гейт. REMOTE_USER непуст только у запросов,
        // прошедших nginx admin-basic-auth (^~ /admin, ^~ /api/admin); обходы через
        // front-controller (/index.php/api/admin, /API/admin) попадают в auth_basic off
        // → REMOTE_USER пуст → 403. В local/testing гейт выключен (см. config/admin.php).
        if (config('admin.basic_auth_gate')) {
            $remoteUser = (string) $request->server('REMOTE_USER', '');
            $allowlist = (array) config('admin.basic_auth_allowlist', []);
            if ($remoteUser === '' || ! in_array($remoteUser, $allowlist, true)) {
                abort(403, 'Доступ в админ-зону запрещён.');
            }
        }
```

Обновить докблок класса: убрать «middleware больше не закрывает зону на проде: дверь держит nginx»; дописать, что nginx-дверь дополнена app-слойным fail-closed гейтом по `REMOTE_USER` (M-1, 21.06), закрывающим front-controller-обходы.

- [ ] **Step 5: Запустить тест — убедиться, что проходит**

Run: `cd app && php artisan test tests/Feature/Admin/EnsureSaasAdminGateTest.php`
Expected: PASS (4/4).

- [ ] **Step 6: Регрессия admin-тестов**

Run: `cd app && php artisan test tests/Feature/Admin/`
Expected: PASS — существующие admin-тесты не ломаются (гейт по умолчанию выключен в testing).

- [ ] **Step 7: Pint + Larastan**

Run: `cd app && vendor/bin/pint app/Http/Middleware/EnsureSaasAdmin.php config/admin.php tests/Feature/Admin/EnsureSaasAdminGateTest.php && composer stan`
Expected: pint passed; phpstan 0 errors. Если Larastan ругнётся на `getJson()`-count в новом тест-файле — добавить запись в `app/phpstan-baseline.neon` (паттерн `method.notFound` для `tests/Feature/Admin/EnsureSaasAdminGateTest.php`, count = число `getJson`-вызовов = 4).

- [ ] **Step 8: Документировать env-ключи в `.env.example`**

В `app/.env.example` дописать (рядом с прочими секциями, не в случайное место):

```
# SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth, допущенные в /api/admin/*.
ADMIN_ALLOWED_USERS=admin
# ADMIN_GATE_ENFORCED=true  # авто: true вне local/testing
```

- [ ] **Step 9: Commit (с разрешения владельца)**

Перед коммитом расстейджить observer-файлы (авто-регенерятся post-commit):
`git restore --staged docs/observer/STATUS.md docs/observer/.pii-counters.json 2>/dev/null`

```bash
git add app/config/admin.php app/app/Http/Middleware/EnsureSaasAdmin.php \
  app/tests/Feature/Admin/EnsureSaasAdminGateTest.php app/.env.example \
  docs/superpowers/specs/2026-06-21-m1-admin-gate-fail-closed-design.md \
  docs/superpowers/plans/2026-06-21-m1-admin-gate-fail-closed.md
# + app/phpstan-baseline.neon если правился в Step 7
git commit -m "fix(security): fail-closed app-гейт админ-зоны по REMOTE_USER + allowlist (M-1)"
```

---

## Self-Review

**Spec coverage:** config/admin.php (allowlist + gate) ✓; EnsureSaasAdmin fail-closed + сохранён impersonation-блок ✓; тест 4 кейса (нет user→403 / allowlist→pass / eve→403 / gate-off→pass) ✓; 403 не 503 ✓; без схемы ✓; env-документация ✓; деплой — шаг владельца (вне плана) ✓.

**Placeholder scan:** код приведён полностью во всех шагах; команды с ожидаемым выводом. Нет TBD/TODO-заглушек.

**Type consistency:** `config('admin.basic_auth_gate')` / `config('admin.basic_auth_allowlist')` — идентичны в config-файле, middleware и тесте. `REMOTE_USER` читается через `$request->server('REMOTE_USER', '')`. Ключи env `ADMIN_ALLOWED_USERS` / `ADMIN_GATE_ENFORCED` совпадают между config и .env.example.
