8.8 KiB
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-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
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.