Files
portal/docs/superpowers/plans/2026-06-27-admin-db-connection-path-a.md
T
Дмитрий 5fe6620bcf 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

379 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 работают без отдельной роли.