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

8.8 KiB
Raw Blame History

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.


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-adminEnsureSaasAdmin (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

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

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);, вставить:

        // 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

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.