Files
portal/docs/superpowers/plans/2026-06-27-admin-db-connection-path-a.md
T
Дмитрий 737d2e192b docs(админка): уточнённая спецификация + план фикса доступа через crm_admin_user
Поправка по факту кода: реально сломаны только AdminTenantsController и
AdminBillingController (ходят под default crm_app_user); Incidents/Pd/
SupplierIntegration/Impersonation уже используют pgsql_supplier и работают.
План: connection pgsql_admin + middleware UseAdminConnection (admin-db).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:21:00 +03:00

16 KiB
Raw Blame History

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

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(...), добавить:

        // Путь А (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
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

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

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

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;):

use App\Http\Middleware\UseAdminConnection;

3b. В массив $middleware->alias([...]) добавить строку (после 'saas-admin' => EnsureSaasAdmin::class,):

            'admin-db' => UseAdminConnection::class,
  • Step 4: Attach to the admin group

В app/routes/web.php найти строку:

Route::middleware('saas-admin')->group(function () {

Заменить на:

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
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 что-то менял)
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 работают без отдельной роли.