# 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
<?php

declare(strict_types=1);

it('defines pgsql_admin connection sharing pgsql base under crm_admin_user env', function () {
    $admin = config('database.connections.pgsql_admin');
    $base = config('database.connections.pgsql');

    expect($admin)->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
<?php

declare(strict_types=1);

use App\Http\Middleware\UseAdminConnection;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

it('switches default connection to pgsql_admin during the request', function () {
    $original = DB::getDefaultConnection();
    $seen = null;

    $response = (new UseAdminConnection())->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
<?php

declare(strict_types=1);

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

/**
 * Переключает активное подключение к БД на pgsql_admin (роль crm_admin_user)
 * на время обработки SaaS-admin запроса и восстанавливает прежнее в finally.
 *
 * Зачем: после переезда на Managed PG (Путь А) AdminTenantsController и
 * AdminBillingController ходят под default-ролью crm_app_user, у которой нет
 * cross-tenant доступа (RLS tenants_self_isolation) → пустые «Тенанты»/«Биллинг».
 * crm_admin_user имеет политику srv_bypass + GRANT на админ-таблицы.
 *
 * Ставится ПОСЛЕ saas-admin (EnsureSaasAdmin), чтобы гейт и проверка
 * impersonation прошли под исходным подключением. Контроллеры, явно прибитые к
 * pgsql_supplier, не затрагиваются — меняется только default.
 *
 * См. docs/superpowers/specs/2026-06-27-admin-db-connection-path-a-design.md
 */
class UseAdminConnection
{
    public function handle(Request $request, Closure $next): Response
    {
        $previous = DB::getDefaultConnection();
        DB::setDefaultConnection('pgsql_admin');

        try {
            return $next($request);
        } finally {
            DB::setDefaultConnection($previous);
        }
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

Run: `php artisan test tests/Unit/Middleware/UseAdminConnectionTest.php`
Expected: PASS (оба теста).

- [ ] **Step 5: Commit**

```bash
git add app/app/Http/Middleware/UseAdminConnection.php app/tests/Unit/Middleware/UseAdminConnectionTest.php
git commit -m "feat(админка): middleware UseAdminConnection — swap на pgsql_admin для admin-группы"
```

---

### Task 3: Регистрация alias + подключение к admin-группе

**Files:**
- Modify: `app/bootstrap/app.php` (import + alias)
- Modify: `app/routes/web.php` (группа `saas-admin`)
- Test: `app/tests/Feature/Admin/AdminConnectionMiddlewareWiringTest.php`

- [ ] **Step 1: Write the failing test**

Create `app/tests/Feature/Admin/AdminConnectionMiddlewareWiringTest.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Route;

it('applies admin-db middleware to the admin api route group', function () {
    $route = collect(Route::getRoutes()->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 работают без отдельной роли.
