# Admin DB Connection (Путь А) 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:** Вернуть SaaS-админке доступ к данным в разделах «Тенанты» и «Биллинг», подключив их к базе под ролью `crm_admin_user`. **Architecture:** Новое подключение `pgsql_admin` (роль `crm_admin_user`, у которой политика `srv_bypass` + GRANT на админ-таблицы) + middleware `UseAdminConnection` (alias `admin-db`), который на время обработки admin-запроса меняет default-подключение на `pgsql_admin` и восстанавливает прежнее. Чинит ровно контроллеры, ходящие под default (`AdminTenantsController`, `AdminBillingController`); контроллеры, прибитые к `pgsql_supplier`, не затрагиваются. **Tech Stack:** Laravel 13, PostgreSQL 16 (Managed PG), Pest 4. **Spec:** `docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md` --- ## File Structure - Modify: `app/config/database.php` — добавить connection `pgsql_admin`. - Create: `app/app/Http/Middleware/UseAdminConnection.php` — переключатель подключения. - Modify: `app/bootstrap/app.php` — alias `admin-db` + import. - Modify: `app/routes/web.php` — добавить `admin-db` в группу `saas-admin`. - Create: `app/tests/Unit/Database/PgsqlAdminConnectionTest.php` — конфиг подключения. - Create: `app/tests/Unit/Middleware/UseAdminConnectionTest.php` — поведение middleware. - Create: `app/tests/Feature/Admin/AdminConnectionMiddlewareWiringTest.php` — middleware в пайплайне admin-группы. Все команды выполняются из каталога `app/` (Laravel-корень внутри репозитория). --- ### Task 1: Connection `pgsql_admin` в конфиге **Files:** - Modify: `app/config/database.php` (после блока `'pgsql_supplier' => ...`) - Test: `app/tests/Unit/Database/PgsqlAdminConnectionTest.php` - [ ] **Step 1: Write the failing test** Create `app/tests/Unit/Database/PgsqlAdminConnectionTest.php`: ```php not->toBeNull(); expect($admin['driver'])->toBe('pgsql'); // делит базовый pgsql-конфиг (host/database/sslmode), отличается только ролью expect($admin['host'])->toBe($base['host']); expect($admin['database'])->toBe($base['database']); expect($admin['sslmode'])->toBe($base['sslmode']); // username берётся из DB_ADMIN_USERNAME с fallback на DB_USERNAME (dev) expect($admin['username'])->toBe(env('DB_ADMIN_USERNAME', env('DB_USERNAME', 'root'))); expect($admin['password'])->toBe(env('DB_ADMIN_PASSWORD', env('DB_PASSWORD', ''))); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test tests/Unit/Database/PgsqlAdminConnectionTest.php` Expected: FAIL — `config('database.connections.pgsql_admin')` равно null. - [ ] **Step 3: Add the connection** В `app/config/database.php`, сразу после закрывающей `),` блока `'pgsql_supplier' => array_merge(...)`, добавить: ```php // Путь А (27.06.2026): dedicated PG connection для SaaS-admin зоны под // ролью crm_admin_user (политика srv_bypass = видит все тенанты + GRANT на // админ-таблицы). Используется через middleware UseAdminConnection (alias // admin-db) на группе saas-admin: AdminTenantsController / AdminBillingController // ходят под default → получают cross-tenant доступ. На dev fallback на // DB_USERNAME/DB_PASSWORD (postgres superuser). На prod ОБЯЗАТЕЛЬНО задать // DB_ADMIN_USERNAME=crm_admin_user + DB_ADMIN_PASSWORD. // См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md 'pgsql_admin' => array_merge( $pgsqlConnection, [ 'username' => env('DB_ADMIN_USERNAME', env('DB_USERNAME', 'root')), 'password' => env('DB_ADMIN_PASSWORD', env('DB_PASSWORD', '')), ] ), ``` - [ ] **Step 4: Run test to verify it passes** Run: `php artisan test tests/Unit/Database/PgsqlAdminConnectionTest.php` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add app/config/database.php app/tests/Unit/Database/PgsqlAdminConnectionTest.php git commit -m "feat(админка): connection pgsql_admin под ролью crm_admin_user (Путь А)" ``` --- ### Task 2: Middleware `UseAdminConnection` **Files:** - Create: `app/app/Http/Middleware/UseAdminConnection.php` - Test: `app/tests/Unit/Middleware/UseAdminConnectionTest.php` - [ ] **Step 1: Write the failing test** Create `app/tests/Unit/Middleware/UseAdminConnectionTest.php`: ```php handle( Request::create('/api/admin/tenants'), function () use (&$seen) { $seen = DB::getDefaultConnection(); return response('ok'); } ); expect($seen)->toBe('pgsql_admin'); expect($response->getContent())->toBe('ok'); expect(DB::getDefaultConnection())->toBe($original); // восстановлено }); it('restores the default connection even when downstream throws', function () { $original = DB::getDefaultConnection(); $call = fn () => (new UseAdminConnection())->handle( Request::create('/api/admin/tenants'), function () { throw new RuntimeException('boom'); } ); expect($call)->toThrow(RuntimeException::class); expect(DB::getDefaultConnection())->toBe($original); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test tests/Unit/Middleware/UseAdminConnectionTest.php` Expected: FAIL — класс `App\Http\Middleware\UseAdminConnection` не существует. - [ ] **Step 3: Create the middleware** Create `app/app/Http/Middleware/UseAdminConnection.php`: ```php getRoutes()) ->first(fn ($r) => $r->uri() === 'api/admin/tenants'); expect($route)->not->toBeNull(); expect($route->gatherMiddleware())->toContain('admin-db'); // saas-admin по-прежнему в пайплайне (гейт не потерян) expect($route->gatherMiddleware())->toContain('saas-admin'); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `php artisan test tests/Feature/Admin/AdminConnectionMiddlewareWiringTest.php` Expected: FAIL — `gatherMiddleware()` не содержит `admin-db`. - [ ] **Step 3: Register the alias** В `app/bootstrap/app.php`: 3a. Добавить import рядом с прочими middleware-импортами (после `use App\Http\Middleware\ImpersonationContext;`): ```php use App\Http\Middleware\UseAdminConnection; ``` 3b. В массив `$middleware->alias([...])` добавить строку (после `'saas-admin' => EnsureSaasAdmin::class,`): ```php 'admin-db' => UseAdminConnection::class, ``` - [ ] **Step 4: Attach to the admin group** В `app/routes/web.php` найти строку: ```php Route::middleware('saas-admin')->group(function () { ``` Заменить на: ```php Route::middleware(['saas-admin', 'admin-db'])->group(function () { ``` (Порядок важен: `saas-admin` первым — гейт/impersonation; `admin-db` вторым — смена подключения после гейта.) - [ ] **Step 5: Run test to verify it passes** Run: `php artisan test tests/Feature/Admin/AdminConnectionMiddlewareWiringTest.php` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add app/bootstrap/app.php app/routes/web.php app/tests/Feature/Admin/AdminConnectionMiddlewareWiringTest.php git commit -m "feat(админка): admin-db middleware в группе saas-admin (alias + routing)" ``` --- ### Task 4: Регресс и сводный прогон **Files:** нет правок кода — только проверки. - [ ] **Step 1: Прогнать полный admin/auth/middleware-срез** Run: `php artisan test tests/Feature/Admin tests/Unit/Middleware tests/Unit/Database tests/Feature/SaasAdminMiddlewareTest.php tests/Feature/Admin/EnsureSaasAdminGateTest.php` Expected: PASS — новые тесты зелёные, существующие admin/гейт-тесты не сломаны. - [ ] **Step 2: Larastan на затронутых файлах** Run: `composer stan` Expected: 0 новых ошибок в `UseAdminConnection.php`, `config/database.php`, `bootstrap/app.php`, `routes/web.php`. - [ ] **Step 3: Pint-формат** Run: `composer pint -- app/Http/Middleware/UseAdminConnection.php config/database.php bootstrap/app.php routes/web.php` Expected: без изменений или авто-отформатировано (тогда добавить в коммит). - [ ] **Step 4: rls-reviewer проверка изоляции** Запустить агента `rls-reviewer` с вопросом: «правка добавляет connection pgsql_admin (crm_admin_user) и middleware-swap только на группе saas-admin; не ослабляет ли это изоляцию tenant-facing запросов?». Ожидаемый вердикт: tenant-facing группа (`tenant`) не затронута, изоляция сохранена. - [ ] **Step 5: Commit (если Pint что-то менял)** ```bash git add -A git commit -m "chore(админка): формат/линт после admin-db middleware" ``` --- ### Task 5: Выкат на боевой liderra.ru (по отдельному разрешению владельца) > ⚠️ Необратимое действие на проде. Выполнять ТОЛЬКО после явного «выкатывай» от владельца и зелёного `prod-deploy-validator`. - [ ] **Step 1: Pre-flight** Запустить агента `prod-deploy-validator` («проверь готовность боевого»). Ожидаемо: GO. - [ ] **Step 2: Влить ветку в gitea main и выкатить код** Код-выкат — штатный rsync overlay gitea→прод (исключает `.env/storage/vendor/.git`, БД не трогает). Команда — как в существующем `redeploy.sh` на проде. - [ ] **Step 3: Прописать env-ключи admin-роли на проде** На сервере добавить в `/var/www/liderra/app/.env` (пароль — из `/home/ubuntu/liderra-secrets.txt`, ключ `crm_admin_user`, либо из Lockbox): ``` DB_ADMIN_USERNAME=crm_admin_user DB_ADMIN_PASSWORD=<пароль роли crm_admin_user> ``` - [ ] **Step 4: Пере-кэшировать конфиг** Run (на проде, под нужным пользователем — см. quirk 107, НЕ под root): `php artisan config:cache` Expected: `Configuration cached successfully`. - [ ] **Step 5: Live-приёмка (acceptance)** 5a. Под ролью `crm_admin_user` `AdminTenantsController`-запрос видит все тенанты: скрипт-замер «default-роль vs admin-роль» (как в spec, раздел «Проблема») — `tenants` под admin-ролью = 6, `balance_transactions` > 0. 5b. Изоляция жива: tenant-facing эндпоинт под `crm_app_user` видит только свой тенант. 5c. Визуально в браузере: `liderra.ru/admin/tenants` показывает клиентов; `liderra.ru/admin/billing` показывает суммы; карточка тенанта (клик по клиенту) показывает баланс/сделки. 5d. Запись: правка баланса тенанта в админке проходит и пишет `saas_admin_audit_log`. - [ ] **Step 6: Зафиксировать снимок** Обновить `ПИЛОТ.md` (раздел БД/админка) + память: фикс выкачен, дата, что проверено. --- ## Notes / Известные допущения - Шов №4 (тест под суперпользователем не ловит RLS) закрывается live-приёмкой (Task 5 Step 5), не автотестом — это осознанно (см. spec). - Контроллеры на `pgsql_supplier` (Incidents/Pd/SupplierIntegration/Impersonation) намеренно не трогаем — работают. Унификация на `crm_admin_user` — возможный отдельный follow-up, не входит в этот план. - Если в `app/.env` на dev нет `DB_ADMIN_USERNAME`, connection падает на `DB_USERNAME` (postgres superuser) — тесты Task 1–4 работают без отдельной роли.