12f7080561
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.
Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).
Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.
LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
389 lines
28 KiB
Markdown
389 lines
28 KiB
Markdown
# Переезд базы на управляемую Yandex (Путь А) 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:** Перевести боевую базу liderra.ru на **Yandex Managed Service for PostgreSQL** так, чтобы Яндекс сам делал копии/восстановление/отказоустойчивость, БЕЗ переписывания логики портала. Изоляцию клиентов и аудит сохраняем, переложив их с «особых прав ролей» (BYPASSRLS / session_replication_role, которых на управляемой базе нет) на штатные механизмы PostgreSQL (разрешающие RLS-политики + GUC-метка).
|
||
|
||
**Architecture:** Меняется СЛОЙ РОЛЕЙ/ПОЛИТИК БД, не код портала. Три служебные роли теряют атрибут BYPASSRLS → вместо него получают разрешающие политики `FOR ALL TO <role> USING(true)`. Пересчёт аудит-цепочки перестаёт отключать триггеры через superuser-параметр → триггер пропускает себя по GUC-метке `app.audit_rebuild`. Маскировка ПДн переходит на встроенную в управляемую базу anon 1.3.2. Всё проверяется на **тест-кластере (копии)** до боевого переезда; данные мигрируют дамп/restore (база ~13 МБ → окно простоя минуты).
|
||
|
||
**Tech Stack:** PostgreSQL 16, Yandex Managed Service for PostgreSQL (роли `mdb_superuser`/`mdb_admin`, пулер Odyssey порт 6432, SSL verify-full + CA), Laravel 13 (`config/database.php`, Pest), `pg_dump -Fc`/`pg_restore`, anon 1.3.2 (built-in), `rls-reviewer` агент, `prod-deploy-validator` агент.
|
||
|
||
---
|
||
|
||
> 🔄 **Обновлено 26.06.2026 под правки прод-сессии (проверено по коду до `7efe9e3e`):** схема v8.53→**v8.55** (+2 SaaS-таблицы `supplier_deferred_sync`, `supplier_sync_runs` — без RLS, переносятся дампом как есть, нужен только GRANT `crm_supplier_worker`, уже в blanket-гранте). Дамп для переезда берём из **текущего** прода (там уже live разблокировка смены источника + маршрутизация по снимку). `SharesSupplierPdo` — тестовая механика, прогон тестов на тест-кластере (Phase 5) должен её учитывать. Деньги-инвариант — волатилен (см. правило 3).
|
||
|
||
## ⚠️ Правила выполнения (не нарушать)
|
||
|
||
1. **Вся переделка — сперва на ТЕСТ-КЛАСТЕРЕ (копии).** Боевую базу не трогаем, пока изоляция и деньги не подтверждены на копии.
|
||
2. **Боевой переезд — только после явного «выкатывай» владельца** + `prod-deploy-validator` (GO). Помечено `[PROD-GATE]`.
|
||
3. **Деньги-инвариант (ВОЛАТИЛЕН — сверять снимок ДО==ПОСЛЕ одной операции, НЕ с фиксированным числом):** портал живой, числа растут каждый день. На 26.06.2026 прод = `tenant 2 ≈ 1 838 400 ₽ / 1016 сделок (990 живых + 26 удал.) / 999 731 лид` (было 1 836 400 / 1013 на 25.06). Точный текущий запрос и значение фиксируются в Phase 0 непосредственно перед операцией; критерий — равенство снимков до и после, а не совпадение с историческим числом.
|
||
4. **Изоляция:** после смены модели — обязательный прогон `rls-reviewer` + тест «клиент A не видит данных клиента B» на копии. Любая утечка = СТОП.
|
||
5. **Старую базу не гасим** ≥7 дней после переезда (горячий откат: вернуть `DB_HOST`).
|
||
|
||
---
|
||
|
||
## 📋 Для владельца — что делаем простым языком
|
||
|
||
1. Берём у Яндекса **управляемую базу** — сначала **тестовую копию**, чтобы всё проверить, не трогая боевую.
|
||
2. На копии меняем «замки»: служебные роли получают доступ через правила вместо особого права. Портал при этом не переписываем.
|
||
3. Проверяем на копии, что **клиенты не видят чужого** и **деньги целы**.
|
||
4. Налаживаем маскировку персональных данных встроенным средством Яндекса.
|
||
5. В тихое окно (простой — минуты) **переключаем боевой портал** на управляемую базу. Старую держим неделю на всякий случай.
|
||
6. Дальше Яндекс сам делает копии, запасную базу и переключение — **ручного нянченья больше нет**.
|
||
|
||
**Что от вас нужно:** в консоли Яндекса создать тестовый и боевой кластеры + 5 пользователей базы (могу провести по шагам или с доступом сделать сам). Детали — «Prerequisites».
|
||
|
||
---
|
||
|
||
## Prerequisites (действия владельца в консоли Yandex Cloud)
|
||
|
||
- [ ] **P1. Тест-кластер** Managed PostgreSQL 16, 1 хост (network-ssd), БД `liderra`, в той же сети `ru-central1`. Для проверок HA не нужен.
|
||
- [ ] **P2. 5 пользователей БД** через консоль (БЕЗ возможности BYPASSRLS — её там и нет): `crm_app_user`, `crm_admin_user`, `crm_migrator`, `crm_audit_writer`, `crm_supplier_worker`. Пароли — в Lockbox.
|
||
- [ ] **P3. CA-сертификат** кластера (`https://storage.yandexcloud.net/cloud-certs/CA.pem`) — для шифрованного подключения.
|
||
- [ ] **P4. Включить расширения** на кластере (консоль/CLI): `pgcrypto`, `pg_trgm`, `btree_gin`, `pgaudit`, `anon` (1.3.2) — через Shared preload libraries + список расширений БД.
|
||
- [ ] **P5. Боевой кластер** Managed PostgreSQL 16, **HA: ≥2 хоста** (мастер + кворумная реплика), хранение копий 30 дней, PITR включён. Создаётся ПЕРЕД Phase 6, не раньше.
|
||
|
||
> Пароли/ключи — в YC Lockbox, в `.env` сервера и `app/.env.production`; в git не попадают (gitleaks).
|
||
|
||
---
|
||
|
||
## Phase 0 — Discovery + точный money-check
|
||
|
||
### Task 0: Снять факты с боевого (read-only)
|
||
|
||
**Files:** заметка `docs/superpowers/findings/2026-06-25-db-migration/phase0.md`.
|
||
|
||
- [ ] **Step 1: Зафиксировать money-check (точные таблица/колонка)**
|
||
|
||
Run:
|
||
```bash
|
||
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
|
||
\"SELECT 'balance', balance::text FROM tenants WHERE id=2
|
||
UNION ALL SELECT 'deals', count(*)::text FROM deals WHERE tenant_id=2;\""
|
||
```
|
||
Expected: число порядка `balance|1838400.00`, `deals|1016` (волатильно — на 26.06; зафиксировать **актуальное** значение как эталон «ДО» для сверки «ПОСЛЕ»). Если баланс не в `tenants` — найти реальную таблицу (`\d tenants`), записать точный запрос как «money-check». Добавить также проверку лидов (`999 731` на 26.06) и новых SaaS-таблиц `supplier_deferred_sync`/`supplier_sync_runs` (есть в схеме v8.55).
|
||
|
||
- [ ] **Step 2: Список RLS-таблиц и держателей BYPASSRLS (для шва B)**
|
||
|
||
Run:
|
||
```bash
|
||
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
|
||
\"SELECT tablename FROM pg_tables WHERE schemaname='public' AND rowsecurity ORDER BY 1;\"; \
|
||
echo '---ROLES---'; sudo -u postgres psql -d liderra -At -c \
|
||
\"SELECT rolname FROM pg_roles WHERE rolbypassrls AND rolcanlogin ORDER BY 1;\""
|
||
```
|
||
Expected: ~36 таблиц; роли с BYPASSRLS = `crm_admin_user`, `crm_migrator`, `crm_supplier_worker` (подтвердить, что ровно эти три).
|
||
|
||
- [ ] **Step 3: Объём базы + версия + anon** для оценки переноса.
|
||
```bash
|
||
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
|
||
\"select pg_size_pretty(pg_database_size('liderra'));\"; \
|
||
sudo -u postgres psql -d liderra -At -c \"select default_version from pg_available_extensions where name='anon';\""
|
||
```
|
||
|
||
- [ ] **Step 4: Commit (docs-only)**
|
||
```bash
|
||
git add docs/superpowers/findings/2026-06-25-db-migration/phase0.md
|
||
git commit -m "docs(db-migration): снимок боевого перед Путь А (Phase 0)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 1 — Модель безопасности на тест-кластере (швы A + B)
|
||
|
||
> Цель: на копии воспроизвести изоляцию БЕЗ BYPASSRLS и доказать, что она держит.
|
||
|
||
### Task 1: Залить схему+данные на тест-кластер
|
||
|
||
**Files:** runbook `deploy/db-migration/01-load-test-cluster.md`.
|
||
|
||
- [ ] **Step 1: Снять свежий дамп боевого** (read-only):
|
||
```bash
|
||
ssh liderra-prod "sudo -u postgres pg_dump -Fc -d liderra -f /tmp/liderra-$(date -u +%Y%m%d).dump" && \
|
||
ssh liderra-prod "cat /tmp/liderra-*.dump" > /tmp/liderra.dump
|
||
```
|
||
|
||
- [ ] **Step 2: Restore в тест-кластер под владельцем** (`mdb_superuser`-пользователь кластера):
|
||
```bash
|
||
pg_restore --no-owner --no-privileges --no-acl \
|
||
-h <TEST_FQDN> -p 6432 -U <admin_user> -d liderra \
|
||
"sslmode=verify-full sslrootcert=~/.postgresql/root.crt" /tmp/liderra.dump
|
||
```
|
||
|
||
- [ ] **Step 3: Money-check на тест-кластере** — точный запрос из Task 0. Expected: `1836400.00 / 1013`. Расхождение → разобраться до продолжения.
|
||
|
||
- [ ] **Step 4: Commit runbook**
|
||
```bash
|
||
git add deploy/db-migration/01-load-test-cluster.md
|
||
git commit -m "docs(db-migration): загрузка тест-кластера из дампа боевого"
|
||
```
|
||
|
||
### Task 2: Разрешающие политики для служебных ролей (замена BYPASSRLS)
|
||
|
||
**Files:**
|
||
- Create: `db/migrations/2026_06_26_service_role_bypass_policies.sql`
|
||
- Create: `app/database/migrations/2026_06_26_000001_service_role_bypass_policies.php` (обёртка, выполняет .sql)
|
||
- Test: `app/tests/Feature/Db/ServiceRoleBypassPolicyTest.php`
|
||
|
||
- [ ] **Step 1: Написать падающий тест изоляции/обхода**
|
||
|
||
`ServiceRoleBypassPolicyTest.php` — два утверждения: (1) `crm_app_user` с tenant-контекстом видит только своего; (2) служебная роль (эмуляция: разрешающая политика присутствует) видит cross-tenant. На тест-БД (sqlite/pg) проверяем через наличие политики `srv_bypass`:
|
||
```php
|
||
public function test_service_bypass_policies_exist_on_all_rls_tables(): void
|
||
{
|
||
$rls = DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public' AND rowsecurity");
|
||
foreach ($rls as $t) {
|
||
$pol = DB::select(
|
||
"SELECT 1 FROM pg_policies WHERE tablename = ? AND policyname = 'srv_bypass'",
|
||
[$t->tablename]
|
||
);
|
||
$this->assertNotEmpty($pol, "Нет srv_bypass на {$t->tablename}");
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Прогнать — упадёт** (политик ещё нет).
|
||
Run: `composer test -- --filter=ServiceRoleBypassPolicyTest`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Написать SQL-миграцию (идемпотентную)**
|
||
|
||
`db/migrations/2026_06_26_service_role_bypass_policies.sql`:
|
||
```sql
|
||
-- Заменяет атрибут BYPASSRLS на разрешающие политики для 3 служебных ролей.
|
||
-- crm_app_user НЕ получает политику → остаётся tenant-isolated.
|
||
-- crm_audit_writer НЕ получает (append-only, без bypass).
|
||
DO $$
|
||
DECLARE t record;
|
||
BEGIN
|
||
FOR t IN SELECT tablename FROM pg_tables
|
||
WHERE schemaname='public' AND rowsecurity
|
||
LOOP
|
||
EXECUTE format('DROP POLICY IF EXISTS srv_bypass ON public.%I;', t.tablename);
|
||
EXECUTE format(
|
||
'CREATE POLICY srv_bypass ON public.%I AS PERMISSIVE FOR ALL
|
||
TO crm_admin_user, crm_migrator, crm_supplier_worker
|
||
USING (true) WITH CHECK (true);', t.tablename);
|
||
END LOOP;
|
||
END $$;
|
||
```
|
||
Laravel-обёртка `2026_06_26_000001_service_role_bypass_policies.php` — `DB::unprepared(file_get_contents(base_path('../db/migrations/2026_06_26_service_role_bypass_policies.sql')))` + запись в `db/CHANGELOG_schema.md`.
|
||
|
||
- [ ] **Step 4: Применить на тест-кластер + прогнать тест — PASS**
|
||
```bash
|
||
psql -h <TEST_FQDN> -p 6432 -U <admin_user> -d liderra -f db/migrations/2026_06_26_service_role_bypass_policies.sql
|
||
composer test -- --filter=ServiceRoleBypassPolicyTest
|
||
```
|
||
Expected: политики на всех ~36 таблицах; тест PASS.
|
||
|
||
- [ ] **Step 5: Применить GRANT'ы (адаптированный 02_grants под владельца) + сменить пароли/роли на тест-кластере** (`db/02_grants.sql`, запуск под `mdb_superuser`-пользователем вместо postgres).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
```bash
|
||
git add db/migrations/2026_06_26_service_role_bypass_policies.sql app/database/migrations/2026_06_26_000001_service_role_bypass_policies.php app/tests/Feature/Db/ServiceRoleBypassPolicyTest.php db/CHANGELOG_schema.md
|
||
git commit -m "feat(db): разрешающие политики служебных ролей вместо BYPASSRLS (Managed PG)"
|
||
```
|
||
|
||
### Task 3: Проверка изоляции на тест-кластере (живая)
|
||
|
||
- [ ] **Step 1: Подключиться как `crm_app_user`** (не-bypass) к тест-кластеру, поставить контекст tenant 2, проверить, что видны ТОЛЬКО его сделки:
|
||
```bash
|
||
psql -h <TEST_FQDN> -p 6432 -U crm_app_user -d liderra "sslmode=verify-full sslrootcert=~/.postgresql/root.crt" \
|
||
-c "BEGIN; SET LOCAL app.current_tenant_id = 2; SELECT count(*) FROM deals; \
|
||
SET LOCAL app.current_tenant_id = 999; SELECT count(*) FROM deals; ROLLBACK;"
|
||
```
|
||
Expected: для tenant 2 — 1013; для несуществующего 999 — 0. (Изоляция держит без BYPASSRLS.)
|
||
|
||
- [ ] **Step 2: Подключиться как `crm_supplier_worker`** — убедиться, что cross-tenant виден (разрешающая политика работает): `SELECT count(DISTINCT tenant_id) FROM deals;` > 1.
|
||
|
||
- [ ] **Step 3: Прогнать `rls-reviewer`** на схему с новыми политиками. Вердикт без orphan/leak. Если замечания — исправить и повторить Task 2.
|
||
|
||
---
|
||
|
||
## Phase 2 — Пересчёт аудита без session_replication_role (шов C, TDD)
|
||
|
||
### Task 4: GUC-метка вместо отключения триггеров
|
||
|
||
**Files:**
|
||
- Modify: `db/schema.sql` (функция `audit_block_mutation`, ~строка 3320)
|
||
- Modify: `app/app/Console/Commands/AuditRebuildChain.php:107,137`
|
||
- Test: `app/tests/Feature/AuditRebuildChainTest.php`
|
||
|
||
- [ ] **Step 1: Написать падающий тест** — пересчёт цепочки в партиции проходит без `session_replication_role`, при выставленном `app.audit_rebuild='on'`, и аудит остаётся append-only без метки:
|
||
```php
|
||
public function test_rebuild_allowed_only_with_guc_flag(): void
|
||
{
|
||
// без флага — UPDATE log_hash блокируется триггером
|
||
$this->expectException(\Illuminate\Database\QueryException::class);
|
||
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = (SELECT min(id) FROM activity_log)");
|
||
}
|
||
public function test_rebuild_passes_with_guc(): void
|
||
{
|
||
DB::transaction(function () {
|
||
DB::statement("SET LOCAL app.audit_rebuild = 'on'");
|
||
DB::statement("UPDATE activity_log SET log_hash = log_hash WHERE id = (SELECT min(id) FROM activity_log)");
|
||
});
|
||
$this->assertTrue(true); // не упало
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Прогнать — упадёт** (триггер пока знает только session_replication_role).
|
||
Run: `composer test -- --filter=AuditRebuildChainTest`
|
||
Expected: FAIL на `test_rebuild_passes_with_guc` (триггер блокирует UPDATE).
|
||
|
||
- [ ] **Step 3: Изменить функцию `audit_block_mutation()`** (в `db/schema.sql` + миграция):
|
||
```sql
|
||
CREATE OR REPLACE FUNCTION audit_block_mutation() RETURNS trigger AS $$
|
||
BEGIN
|
||
-- Разрешить пересчёт цепочки доверенным процессом по метке (вместо superuser-параметра).
|
||
IF current_setting('app.audit_rebuild', true) = 'on'
|
||
AND current_user IN ('crm_migrator','crm_admin_user') THEN
|
||
RETURN NEW;
|
||
END IF;
|
||
RAISE EXCEPTION 'audit table is append-only (%.%)', TG_TABLE_SCHEMA, TG_TABLE_NAME;
|
||
END;
|
||
$$ LANGUAGE plpgsql;
|
||
```
|
||
Запись в `db/CHANGELOG_schema.md`.
|
||
|
||
- [ ] **Step 4: Изменить `AuditRebuildChain.php`** — обернуть пересчёт в `DB::connection('pgsql_supplier')->transaction(...)` и внутри `SET LOCAL app.audit_rebuild = 'on'` вместо строк 107/137 (`session_replication_role`). Убрать сброс на `'origin'` (SET LOCAL сам сбрасывается в конце транзакции; это ещё и Odyssey-safe).
|
||
|
||
- [ ] **Step 5: Прогнать — PASS** + `composer pint && composer stan`.
|
||
Run: `composer test -- --filter=AuditRebuildChainTest`
|
||
Expected: PASS оба теста.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
```bash
|
||
git add db/schema.sql app/app/Console/Commands/AuditRebuildChain.php app/tests/Feature/AuditRebuildChainTest.php db/CHANGELOG_schema.md
|
||
git commit -m "fix(audit): пересчёт цепочки через GUC-метку app.audit_rebuild (без superuser)"
|
||
```
|
||
|
||
> Историческая миграция `2026_05_23_hole2_partition_audit_tables.sql:34` (тоже `session_replication_role`) при переезде дамп/restore НЕ перезапускается — данные переносятся как есть. Future-proof правка той миграции — follow-up, не блокер.
|
||
|
||
---
|
||
|
||
## Phase 3 — Маскировка ПДн на встроенной anon (шов D)
|
||
|
||
### Task 5: Применить метки маскировки на тест-кластере
|
||
|
||
**Files:** `db/anon_masking_labels.sql` (метки уже написаны), runbook `deploy/db-migration/03-masking.md`.
|
||
|
||
- [ ] **Step 1: Включить anon на кластере** (Prerequisite P4) и применить метки:
|
||
```bash
|
||
psql -h <TEST_FQDN> -p 6432 -U <admin_user> -d liderra -c "LOAD 'anon';" -f db/anon_masking_labels.sql
|
||
```
|
||
Если какая-то функция отсутствует в 1.3.2 — заменить на доступный аналог (`partial`, `partial_email`, `fake_first_name/last_name`, `MASKED WITH VALUE NULL` — стандартные, ожидаются присутствующими).
|
||
|
||
- [ ] **Step 2: Проверить правила маскирования**
|
||
```bash
|
||
psql ... -c "SELECT relname, attname, masking_function FROM anon.pg_masking_rules ORDER BY 1,2;"
|
||
```
|
||
Expected: ~16 правил (users/deals/supplier_leads/pd_*/auth_log/tenants), как в `anon_masking_labels.sql`.
|
||
|
||
- [ ] **Step 3: Тест маскированного дампа** — снять дамп ролью с меткой MASKED, убедиться, что телефоны/почта замаскированы, а `tenant_id`/ключи целы; money-check на восстановленном из маскированного дампа НЕ обязан совпадать по ПДн, но числа сделок целы.
|
||
|
||
- [ ] **Step 4: Commit runbook**
|
||
```bash
|
||
git add deploy/db-migration/03-masking.md
|
||
git commit -m "docs(db-migration): маскировка ПДн на встроенной anon (тест-кластер)"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 4 — Подключение приложения (шов: SSL + Odyssey)
|
||
|
||
### Task 6: Конфиг подключения к управляемой базе
|
||
|
||
**Files:** Modify: `app/config/database.php` (опции SSL), `app/.env.production` (значения).
|
||
|
||
- [ ] **Step 1: Написать падающий тест конфига** — `pgsql` connection отдаёт `sslmode=verify-full` и `sslrootcert`, когда заданы env:
|
||
```php
|
||
public function test_pgsql_uses_verify_full_when_configured(): void
|
||
{
|
||
config(['database.connections.pgsql.sslmode' => 'verify-full',
|
||
'database.connections.pgsql.sslrootcert' => '/etc/liderra/yc-ca.pem']);
|
||
$this->assertSame('verify-full', config('database.connections.pgsql.sslmode'));
|
||
$this->assertNotNull(config('database.connections.pgsql.sslrootcert'));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Прогнать — упадёт** (нет ключа `sslrootcert`).
|
||
Run: `composer test -- --filter=pgsql_uses_verify_full`
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Добавить опции в `$pgsqlConnection`** (`config/database.php:22`):
|
||
```php
|
||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||
'sslrootcert' => env('DB_SSLROOTCERT'), // путь к CA.pem управляемой базы
|
||
```
|
||
(`pgsql_supplier` наследует через `array_merge` — править не нужно.)
|
||
|
||
- [ ] **Step 4: Прогнать — PASS.**
|
||
Run: `composer test -- --filter=pgsql_uses_verify_full`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Подготовить `.env.production` значения** (НЕ коммитить секреты): `DB_HOST=<FQDN кластера>`, `DB_PORT=6432` (Odyssey), `DB_SSLMODE=verify-full`, `DB_SSLROOTCERT=/etc/liderra/yc-ca.pem`, `DB_SUPPLIER_USERNAME=crm_supplier_worker`. Положить CA на сервер.
|
||
|
||
- [ ] **Step 6: Commit (код, без секретов)**
|
||
```bash
|
||
git add app/config/database.php
|
||
git commit -m "feat(db): SSL verify-full + CA для подключения к управляемой базе"
|
||
```
|
||
|
||
---
|
||
|
||
## Phase 5 — Полный прогон на копии (генеральная репетиция)
|
||
|
||
### Task 7: Прогнать портал против тест-кластера
|
||
|
||
- [ ] **Step 1:** Поднять приложение (локально/стейдж) с `.env`, указывающим на тест-кластер (port 6432, verify-full).
|
||
- [ ] **Step 2: Регрессия** — `composer test` (Pest, backend+billing+RLS), Vitest. Expected: зелено (RLS/изоляция/деньги).
|
||
- [ ] **Step 3: Живой смоук** — вход клиентом, открыть сделки (видит свои), приём тестового лида через `pgsql_supplier`-путь (cross-tenant запись проходит), пересчёт аудита `audit:rebuild-chain` на партиции (работает через GUC), `audit:verify-chains` intact.
|
||
- [ ] **Step 4: Money-check** на тест-кластере: `1836400.00 / 1013`.
|
||
- [ ] **Step 5:** Зафиксировать результат репетиции в `docs/superpowers/findings/2026-06-25-db-migration/dry-run.md` + commit (docs-only).
|
||
|
||
---
|
||
|
||
## Phase 6 — Боевой переезд `[PROD-GATE]`
|
||
|
||
### Task 8: Переключение боевого на управляемый кластер (окно, простой минуты)
|
||
|
||
**Files:** runbook `deploy/db-migration/06-cutover.md`.
|
||
|
||
- [ ] **Step 1: Запросить GO** — `prod-deploy-validator` + явное «выкатывай». NO-GO → стоп.
|
||
- [ ] **Step 2:** Создать боевой кластер (Prerequisite P5, HA ≥2 хоста), накатить роли/политики/grants/маскировку как на тесте (всё уже отлажено).
|
||
- [ ] **Step 3: Окно обслуживания** — включить maintenance на портале (очередь на паузу).
|
||
- [ ] **Step 4: Финальный дамп боевого → restore в боевой кластер**:
|
||
```bash
|
||
ssh liderra-prod "sudo -u postgres pg_dump -Fc -d liderra" > /tmp/final.dump
|
||
pg_restore --no-owner --no-privileges -h <PROD_FQDN> -p 6432 -U <admin_user> -d liderra "sslmode=verify-full sslrootcert=..." /tmp/final.dump
|
||
```
|
||
- [ ] **Step 5:** Применить политики `srv_bypass` + grants + метки маскировки на боевом кластере.
|
||
- [ ] **Step 6: Money-check на новом боевом кластере** — `1836400.00 / 1013`. Расхождение → НЕ переключать, разобраться.
|
||
- [ ] **Step 7:** Переключить `app/.env` на сервере: `DB_HOST=<PROD_FQDN>`, `DB_PORT=6432`, SSL verify-full, CA. `php artisan config:cache` (от www-data, квирк 107), снять maintenance, перезапустить очередь.
|
||
- [ ] **Step 8: Verify** — HTTP 200 (главная+`/login`), вход клиентом видит свои сделки (изоляция), приём тестового лида, очередь активна, 0 свежих ошибок, `audit:verify-chains` intact, money-check ещё раз.
|
||
- [ ] **Step 9: Commit runbook + обновить `ПИЛОТ.md`** (по команде «обнови пилот»): база = управляемая, копии/HA — Яндекс.
|
||
|
||
---
|
||
|
||
## Phase 7 — Завершение
|
||
|
||
### Task 9: Декомиссия и нормативка
|
||
|
||
- [ ] **Step 1:** Старую самоуправляемую базу держать выключенной как откат **≥7 дней**, затем погасить (по команде владельца).
|
||
- [ ] **Step 2:** Снять с сервера старый cron бэкапов/реплику Пути Б — больше не нужны (копии у Яндекса). Скрипты `deploy/db-backup/*` пометить устаревшими.
|
||
- [ ] **Step 3:** Синк нормативки (`normative-sync` агент): `00_create_roles.sql` (модель без BYPASSRLS), CLAUDE.md §2 (база = Managed PG, без BYPASSRLS), память. Через положенные каналы (CLAUDE.md — плагин).
|
||
- [ ] **Step 4:** Закрыть в памяти переход (Путь А done).
|
||
|
||
---
|
||
|
||
## Self-Review (выполнено автором плана)
|
||
|
||
- **Покрытие швов A–F (assessment §3a):** A,B → Task 2; C → Task 4; D → Task 5; E → Task 2 Step 5; F (partition DDL) — наследует роль-владельца кластера, отдельной правки кода не требует (DDL уже через `pgsql_supplier`/migrator). ✅
|
||
- **App-код не переписываем:** подтверждено — меняются только `config/database.php` (SSL, Task 6) и `AuditRebuildChain.php` (Task 4). ~30 джобов на `pgsql_supplier` и ~50 мест `app.current_tenant_id` — НЕ трогаются. ✅
|
||
- **Изоляция проверена дважды:** Task 3 (живой тест app_user vs supplier_worker) + Task 7 Step 2 (regression RLS) + `rls-reviewer`. ✅
|
||
- **Деньги-инвариант** в Task 0/1/5/7/8 (1 836 400.00 / 1013). ✅
|
||
- **Прод-гейты** на всех боевых шагах (Phase 6). ✅
|
||
- **Имена-консистентность:** политика `srv_bypass`, метка `app.audit_rebuild`, 5 ролей, порт 6432 — единообразны во всех задачах. ✅
|
||
- **Открытое (честно):** точная таблица баланса и парность функций anon 1.3.2 подтверждаются в Phase 0/3 на копии, не выдуманы. Историческая миграция с `session_replication_role` — follow-up (не блокер при дамп/restore). ✅
|