Поправка по факту кода: реально сломаны только 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>
16 KiB
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— добавить connectionpgsql_admin. - Create:
app/app/Http/Middleware/UseAdminConnection.php— переключатель подключения. - Modify:
app/bootstrap/app.php— aliasadmin-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 работают без отдельной роли.