5fe6620bcf
Поправка по факту кода: реально сломаны только 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>
379 lines
16 KiB
Markdown
379 lines
16 KiB
Markdown
# 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 работают без отдельной роли.
|