Шов 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>
27 KiB
⛔ ЗАМЕНЁН 25.06.2026. Владелец выбрал Путь А (управляемая база Яндекса) после разбора по коду — швы оказались узкими, портал уже подготовлен. Актуальный план: 2026-06-25-db-migration-path-a-managed.md. Этот файл оставлен для истории; идея «копии на сторону» поглощена автокопиями управляемой базы (3 ЦОД).
База данных — безопасность (Путь Б) 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: Убрать риск потери денег/данных клиентов: копии базы — «на сторону» (вне сервера базы) с проверкой восстановления, плюс живая запасная копия (реплика) и, отдельной поздней фазой, вынос базы на свой сервер. Без переписывания изоляции клиентов (RLS/роли остаются как есть).
Architecture: PostgreSQL остаётся самоуправляемым (наши 5 ролей, BYPASSRLS, anon 3.0.13, session_replication_role — работают как сейчас, ничего не переписываем). Добавляем: (1) выгрузку ночных дампов в Yandex Object Storage (S3) с шифрованием и ротацией; (2) еженедельную проверку «дамп реально восстанавливается»; (3) алерт, если свежий дамп не появился; (4) потоковую реплику на втором хосте для подстраховки; (5) [поздняя фаза] перенос базы на выделенный сервер с repoint приложения и сверкой денег.
Tech Stack: PostgreSQL 16, pg_dump -Fc, Yandex Object Storage (S3-совместимое, endpoint storage.yandexcloud.net), aws-cli (или s3cmd), GPG/SSE для шифрования, streaming replication (pg_basebackup + standby), GitHub Actions (artisan-run.yml / новый workflow) для прод-операций, существующий prod-deploy-validator агент для GO/NO-GO.
⚠️ Правила выполнения (не нарушать)
- Любой шаг на боевом liderra.ru — только после явного «выкатывай» владельца и прогона
prod-deploy-validator(GO). Шаги ниже это помечают как[PROD-GATE]. - Деньги-инвариант: перед и после каждого шага, который касается данных, сверять
tenant 2— должно остаться 1 836 400.00 ₽ / 1013 сделок (точный запрос фиксируется в Phase 0). Любое расхождение = СТОП. - Ничего не удаляем (старые копии, cron) пока новый механизм не проверен на восстановление.
- Доступ к боевому —
ssh liderra-prodчерез бастион (внутр.10.128.0.15, ProxyJumpliderra-bastion).
📋 Для владельца — что делаем простым языком
- Сначала — копии «на сторону». Сейчас ночные копии базы лежат на том же диске, что и сама база. Делаем так, чтобы каждая ночная копия дополнительно улетала в отдельное защищённое хранилище Яндекса (зашифрованной). Если сервер умрёт — копии целы. Это закрывает главную боль.
- Проверка, что копия реально оживает. Раз в неделю автоматически берём свежую копию и проверяем, что из неё база действительно поднимается (а не «копия есть, но битая»).
- Сторож. Если ночью копия не сделалась — приходит сигнал, а не тишина.
- Запасная живая база (реплика). Вторая копия базы, которая всё время повторяет основную — на случай аварии.
- [Позже] Своя база на отдельном сервере — чтобы база и портал не делили одну машину. Делается после п.1–4, отдельно.
Что от вас нужно до старта (это в облаке Яндекса, через консоль — ваши руки или доступ для меня): создать сервисный аккаунт + ключ + бакет (хранилище) для копий. Детали — в «Prerequisites» ниже.
Prerequisites (действия владельца в консоли Yandex Cloud)
Без них Phase 1 не стартует. Это разовая настройка доступа к хранилищу.
- P1. Создать бакет Object Storage для копий, напр.
liderra-db-backups, класс STANDARD, доступ приватный, регионru-central1. - P2. Создать сервисный аккаунт (напр.
liderra-backup-sa) с рольюstorage.uploader(илиstorage.editor) только на этот бакет. - P3. Создать статический ключ доступа для этого сервисного аккаунта (Access Key ID + Secret) — отдать мне для размещения на сервере (в защищённом виде, не в git).
- P4. Включить версионирование бакета + lifecycle (хранить 30 дней, потом удалять) — закрывает ransomware/случайное удаление.
- P5 (рекоменд.). Включить Object Lock (неизменяемость на 14 дней) на бакете — чтобы копии нельзя было перезаписать/удалить даже с ключом.
Ключ из P3 размещается на сервере в
/etc/liderra/s3-backup.env(chmod 600, владелец root), в git не попадает (gitleaks). Долгосрочно — переложить в YC Lockbox.
Phase 0 — Discovery (подтвердить текущее состояние, без изменений)
Task 0: Снять факты с боевого
Files: только чтение по SSH; результат — заметка docs/superpowers/findings/2026-06-25-db-safety/phase0-state.md.
- Step 1: Подтвердить деньги-инвариант (зафиксировать точный запрос)
Run (на боевом, read-only):
ssh liderra-prod "sudo -u postgres psql -d liderra -At -c \
\"SELECT 'balance', balance FROM tenants WHERE id=2
UNION ALL SELECT 'deals', count(*)::text FROM deals WHERE tenant_id=2;\""
Expected: balance|1836400.00 и deals|1013. Записать точные имена таблицы/колонки баланса (если баланс не в tenants, найти реальную: \d tenants, либо balances/saas_*). Этот запрос дальше = «money-check».
- Step 2: Снять параметры текущего бэкап-механизма
Run:
ssh liderra-prod "cat /usr/local/bin/liderra-backup.sh; echo '---CRON---'; \
cat /etc/cron.d/liderra-backup; echo '---LS---'; ls -lh /home/ubuntu/backups/ | tail; \
echo '---LOG---'; tail -20 /var/log/liderra-backup.log"
Expected: увидеть pg_dump -Fc, расписание 03:30, retention 14, наличие свежих файлов.
- Step 3: Снять параметры сервера/диска/PG
Run:
ssh liderra-prod "df -h /; free -m; psql --version 2>/dev/null; \
sudo -u postgres psql -d liderra -At -c 'show wal_level;'; \
sudo -u postgres psql -d liderra -At -c 'select pg_size_pretty(pg_database_size(current_database()));'"
Expected: свободное место на /, объём базы, wal_level (нужен replica/logical для Phase 3), версия PG.
- Step 4: Зафиксировать находки
Записать в phase0-state.md: реальный money-check запрос, объём базы, свободный диск, текущий retention, wal_level, наличие aws/s3cmd на сервере (which aws s3cmd). Commit (docs-only):
git add docs/superpowers/findings/2026-06-25-db-safety/phase0-state.md
git commit -m "docs(db-safety): снимок состояния перед Путь Б (Phase 0)"
Phase 1 — Копии «на сторону» в Object Storage (СРОЧНОЕ, закрывает главный риск)
Task 1: Скрипт выгрузки дампа в S3 (готовим и тестируем ЛОКАЛЬНО логику, ставим на прод за GATE)
Files:
-
Create:
deploy/db-backup/upload-to-s3.sh -
Create:
deploy/db-backup/README.md -
Modify (на сервере, через GATE):
/usr/local/bin/liderra-backup.sh(добавить вызов выгрузки) -
Step 1: Написать скрипт выгрузки
deploy/db-backup/upload-to-s3.sh:
#!/usr/bin/env bash
# Выгрузка свежего дампа PostgreSQL в Yandex Object Storage (S3), зашифрованно.
set -euo pipefail
source /etc/liderra/s3-backup.env # AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET
ENDPOINT="https://storage.yandexcloud.net"
BACKUP_DIR="/home/ubuntu/backups"
GPG_RECIPIENT_KEY="/etc/liderra/backup-pubkey.asc" # публичный ключ для шифрования
# взять самый свежий дамп
LATEST="$(ls -t "${BACKUP_DIR}"/*.dump 2>/dev/null | head -1)"
[ -z "${LATEST}" ] && { echo "FATAL: нет дампов в ${BACKUP_DIR}"; exit 1; }
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
ENC="${LATEST}.${STAMP}.gpg"
# зашифровать дамп публичным ключом (расшифровать сможет только владелец приватного ключа)
gpg --batch --yes --trust-model always --recipient-file "${GPG_RECIPIENT_KEY}" \
--output "${ENC}" --encrypt "${LATEST}"
# выгрузить в бакет
aws --endpoint-url "${ENDPOINT}" s3 cp "${ENC}" \
"s3://${S3_BUCKET}/daily/$(basename "${ENC}")" \
--only-show-errors
# проверить, что объект реально лёг (size > 0)
SIZE="$(aws --endpoint-url "${ENDPOINT}" s3 ls "s3://${S3_BUCKET}/daily/$(basename "${ENC}")" | awk '{print $3}')"
[ "${SIZE:-0}" -gt 0 ] || { echo "FATAL: объект в S3 пустой/не загрузился"; exit 1; }
rm -f "${ENC}"
echo "OK: ${ENC} → s3://${S3_BUCKET}/daily/ (${SIZE} bytes) @ ${STAMP}"
- Step 2: Написать README с порядком установки
deploy/db-backup/README.md: описать /etc/liderra/s3-backup.env (3 переменные, chmod 600), генерацию пары GPG-ключей (приватный хранит владелец вне сервера, публичный — на сервере), endpoint storage.yandexcloud.net, что выгрузка вызывается из liderra-backup.sh ПОСЛЕ успешного pg_dump.
- Step 3: Commit (код, без прод-выката)
git add deploy/db-backup/upload-to-s3.sh deploy/db-backup/README.md
git commit -m "feat(db-safety): скрипт выгрузки дампа базы в Object Storage (зашифровано)"
Task 2: Установка на боевой [PROD-GATE]
-
Step 1: Запросить GO —
prod-deploy-validator+ явное «выкатывай» владельца. NO-GO → стоп. -
Step 2: Разместить секреты и ключ (на сервере)
# s3-backup.env (значения из Prerequisites P3) и публичный GPG-ключ
ssh liderra-prod "sudo install -m 600 -o root -g root /dev/stdin /etc/liderra/s3-backup.env" < local-s3-backup.env
ssh liderra-prod "sudo install -m 644 /dev/stdin /etc/liderra/backup-pubkey.asc" < backup-pubkey.asc
ssh liderra-prod "which aws || sudo apt-get install -y awscli"
- Step 3: Поставить скрипт и подцепить в существующий бэкап
ssh liderra-prod "sudo install -m 755 /dev/stdin /usr/local/bin/liderra-upload-s3.sh" < deploy/db-backup/upload-to-s3.sh
# добавить вызов в конец liderra-backup.sh (после успешного pg_dump)
ssh liderra-prod "grep -q liderra-upload-s3 /usr/local/bin/liderra-backup.sh || \
echo '/usr/local/bin/liderra-upload-s3.sh >> /var/log/liderra-backup.log 2>&1' | \
sudo tee -a /usr/local/bin/liderra-backup.sh"
- Step 4: Прогнать вручную и проверить, что объект появился в бакете
ssh liderra-prod "sudo /usr/local/bin/liderra-upload-s3.sh"
ssh liderra-prod "source /etc/liderra/s3-backup.env; aws --endpoint-url https://storage.yandexcloud.net s3 ls s3://\$S3_BUCKET/daily/ | tail"
Expected: строка OK: ... → s3://.../daily/ (N bytes) и видимый объект в листинге.
-
Step 5: Money-check (ничего не должно поменяться) — повторить запрос из Task 0 Step 1. Expected:
1836400.00 / 1013. -
Step 6: Обновить снимок —
ПИЛОТ.mdстрока про бэкапы: «Off-site (YC Object Storage) — есть» (по команде владельца «обнови пилот»).
Phase 2 — Проверка восстановимости + сторож свежести
Task 3: Еженедельная авто-проверка «дамп оживает»
Files:
-
Create:
deploy/db-backup/restore-test.sh -
Create (на сервере, GATE): cron
/etc/cron.d/liderra-restore-test(еженедельно) -
Step 1: Написать скрипт проверки восстановления
deploy/db-backup/restore-test.sh — берёт последний объект из S3, расшифровывает, pg_restore во временную базу liderra_restore_test, проверяет ключевые числа, затем дропает временную базу:
#!/usr/bin/env bash
set -euo pipefail
source /etc/liderra/s3-backup.env
ENDPOINT="https://storage.yandexcloud.net"
TMP="/tmp/restore-test"; mkdir -p "$TMP"
KEY="$(aws --endpoint-url "$ENDPOINT" s3 ls "s3://${S3_BUCKET}/daily/" | sort | tail -1 | awk '{print $4}')"
aws --endpoint-url "$ENDPOINT" s3 cp "s3://${S3_BUCKET}/daily/${KEY}" "$TMP/latest.gpg" --only-show-errors
gpg --batch --yes --output "$TMP/latest.dump" --decrypt "$TMP/latest.gpg"
sudo -u postgres dropdb --if-exists liderra_restore_test
sudo -u postgres createdb liderra_restore_test
sudo -u postgres pg_restore --no-owner --no-privileges -d liderra_restore_test "$TMP/latest.dump"
# sanity: число сделок tenant 2 не нулевое
CNT="$(sudo -u postgres psql -d liderra_restore_test -At -c 'select count(*) from deals where tenant_id=2;')"
sudo -u postgres dropdb liderra_restore_test
rm -rf "$TMP"
[ "${CNT:-0}" -ge 1000 ] || { echo "FATAL: восстановленная база подозрительна (deals=$CNT)"; exit 1; }
echo "OK: restore-test прошёл, deals(tenant2)=${CNT} @ $(date -u +%FT%TZ)"
- Step 2: Commit
git add deploy/db-backup/restore-test.sh
git commit -m "feat(db-safety): еженедельная проверка восстановимости дампа из S3"
- Step 3:
[PROD-GATE]Поставить + cron еженедельно (воскр. 04:30) + прогнать раз вручную
ssh liderra-prod "sudo install -m 755 /dev/stdin /usr/local/bin/liderra-restore-test.sh" < deploy/db-backup/restore-test.sh
ssh liderra-prod "echo '30 4 * * 0 root /usr/local/bin/liderra-restore-test.sh >> /var/log/liderra-restore-test.log 2>&1' | sudo tee /etc/cron.d/liderra-restore-test"
ssh liderra-prod "sudo /usr/local/bin/liderra-restore-test.sh"
Expected: OK: restore-test прошёл, deals(tenant2)=1013.
Task 4: Сторож свежести копии (алерт, если ночью копии нет)
Files: Create: deploy/db-backup/backup-freshness-check.sh + cron.
- Step 1: Написать проверку свежести — если в бакете нет объекта за последние 26 часов → ненулевой выход (алерт уйдёт по существующему каналу ops-email/GitHub Actions, как у
CsvReconcileJob-алерта):
#!/usr/bin/env bash
set -euo pipefail
source /etc/liderra/s3-backup.env
ENDPOINT="https://storage.yandexcloud.net"
LAST="$(aws --endpoint-url "$ENDPOINT" s3 ls "s3://${S3_BUCKET}/daily/" | sort | tail -1 | awk '{print $1" "$2}')"
LAST_TS="$(date -u -d "$LAST" +%s 2>/dev/null || echo 0)"
NOW="$(date -u +%s)"
AGE_H=$(( (NOW - LAST_TS) / 3600 ))
[ "$AGE_H" -le 26 ] || { echo "ALERT: свежей копии базы в S3 нет ${AGE_H}ч (последняя: ${LAST})"; exit 1; }
echo "OK: свежая копия ${AGE_H}ч назад"
- Step 2: Commit +
[PROD-GATE]поставить, cron ежедневно 06:00 (после ночного бэкапа 03:30):
git add deploy/db-backup/backup-freshness-check.sh
git commit -m "feat(db-safety): сторож свежести копии базы в S3 (алерт при пропуске)"
# на сервере:
ssh liderra-prod "sudo install -m 755 /dev/stdin /usr/local/bin/liderra-backup-freshness.sh" < deploy/db-backup/backup-freshness-check.sh
ssh liderra-prod "echo '0 6 * * * root /usr/local/bin/liderra-backup-freshness.sh || (echo backup-stale | mail -s \"liderra: backup stale\" ops@liderra.ru)' | sudo tee /etc/cron.d/liderra-backup-freshness"
После Phase 2 главный риск закрыт: копии «на стороне», зашифрованы, ротация 30 дней, проверены на восстановление, и есть сигнал если что-то сломалось. Phases 3–4 — усиление, можно делать спокойно.
Phase 3 — Запасная живая база (потоковая реплика)
Task 5: Поднять standby-реплику на втором хосте [PROD-GATE, крупная операция]
Files: Create: deploy/db-replica/setup-standby.md (runbook), правки postgresql.conf/pg_hba.conf на мастере.
Требует второго сервера (provisioning — Prerequisites-аналог в YC, действие владельца). Реплика не трогает данные мастера (только читает WAL).
-
Step 1: Убедиться, что мастер готов к репликации — из Phase 0
wal_level >= replica. Если нет — план правкиpostgresql.conf(wal_level=replica,max_wal_senders=10,wal_keep_size=1GB) + рестарт PG[PROD-GATE](короткий рестарт, в окно). -
Step 2: Создать роль репликации на мастере
-- от postgres на мастере
CREATE ROLE crm_replica WITH REPLICATION LOGIN PASSWORD '<из Lockbox>';
- строка в
pg_hba.conf:host replication crm_replica <ip-реплики>/32 scram-sha-256, затемSELECT pg_reload_conf();.
- Step 3: Снять base backup на реплику
# на сервере-реплике
sudo -u postgres pg_basebackup -h <master-ip> -U crm_replica -D /var/lib/postgresql/16/main -Fp -Xs -P -R
(-R создаёт standby.signal + primary_conninfo.)
- Step 4: Запустить реплику и проверить, что догоняет мастер
# на реплике
sudo systemctl start postgresql
sudo -u postgres psql -At -c "select status, sender_host from pg_stat_wal_receiver;"
# на мастере
sudo -u postgres psql -At -c "select client_addr, state, sync_state from pg_stat_replication;"
Expected: на мастере видно реплику state=streaming; на реплике pg_stat_wal_receiver = streaming.
-
Step 5: Money-check на мастере (реплика не должна влиять):
1836400.00 / 1013. -
Step 6: Runbook переключения (failover) — записать в
setup-standby.mdручную процедуру promote (pg_ctl promote/SELECT pg_promote();) + переключение.envприложения на новый адрес. Авто-failover (Patroni) — отдельный поздний пункт, не сейчас. -
Step 7: Commit (docs/runbook)
git add deploy/db-replica/setup-standby.md
git commit -m "docs(db-safety): runbook standby-реплики + ручной failover (Phase 3)"
Phase 4 — Вынос базы на выделенный сервер (поздняя фаза, после Phase 1–3)
Это про производительность под сотни клиентов (база и приложение перестают делить одну машину), а не про срочную безопасность. Делать отдельным окном с простоем/в тихое время.
Task 6: Перенос базы на отдельный сервер [PROD-GATE, с коротким простоем]
Files: Create: deploy/db-move/runbook.md; Modify (на сервере): app/.env (DB_HOST), pgbouncer.ini.
-
Step 1: Подготовить новый сервер БД — провижен ВМ (4 ядра/8–16 ГБ/SSD с запасом), установить PostgreSQL 16 + все наши расширения (pgcrypto, pg_trgm, btree_gin, pgaudit, anon 3.0.13 — собрать как на текущем по
docs/security/pgaudit-anonymizer-setup.md), создать 5 ролей (db/00_create_roles.sql),pg_hba.confпускает только приложение-сервер. -
Step 2: Вариант переноса — через реплику (минимальный простой): если Phase 3 уже дала реплику на этом сервере — в окно: остановить приложение (maintenance), дождаться нулевого лага,
promoteреплику, переключитьapp/.envDB_HOSTна новый сервер, поднять приложение. Иначе —pg_dump/pg_restoreв окно. -
Step 3: Money-check ДО и ПОСЛЕ на НОВОМ сервере:
1836400.00 / 1013. Расхождение → откат на старыйDB_HOST(старая база нетронута). -
Step 4: Verify портал — HTTP 200 (главная +
/login), очередь активна, свежих ошибок нет, RLS-изоляция жива (зайти двумя тенантами — не видят чужого). -
Step 5: Бэкап-скрипты Phase 1–2 перенацелить на новый сервер; старый сервер БД оставить выключенным как горячий откат на 7 дней, потом погасить.
-
Step 6: Commit runbook + обновить
ПИЛОТ.md(по команде «обнови пилот»).
Phase 5 — Мелкая чистка аудита (необязательно для Б; нужно для будущего Пути А)
Task 7: Пересчёт аудит-цепочки через отдельный superuser-коннекшен (TDD)
На Пути Б это не блокер — на своём сервере у нас есть
postgres. Делаем «по-человечески», чтобы командаaudit:rebuild-chainработала без ручногоsudo -u postgres. Полный redesign (SECURITY DEFINER) откладываем до Пути А.
Files:
-
Modify:
app/config/database.php(добавить connectionpgsql_postgres) -
Modify:
app/app/Console/Commands/AuditRebuildChain.php(использоватьpgsql_postgres) -
Test:
app/tests/Feature/AuditRebuildChainConnectionTest.php -
Step 1: Написать падающий тест — команда
audit:rebuild-chainдолжна использовать connection с правомsession_replication_role(superuser), а неcrm_supplier_worker:
public function test_rebuild_uses_superuser_connection(): void
{
$cmd = app(\App\Console\Commands\AuditRebuildChain::class);
$ref = new \ReflectionMethod($cmd, 'connectionName');
$this->assertSame('pgsql_postgres', $ref->invoke($cmd));
}
-
Step 2: Прогнать — должен упасть (
connectionNameнет / возвращаетpgsql_supplier). Run:composer test -- --filter=AuditRebuildChainConnectionTestExpected: FAIL. -
Step 3: Добавить connection + метод — в
config/database.phpблокpgsql_postgres(envDB_POSTGRES_USERNAME/PASSWORD, на проде =postgres); в команде —protected function connectionName(): string { return 'pgsql_postgres'; }и использовать его в местах сsession_replication_role. -
Step 4: Прогнать — PASS. Run:
composer test -- --filter=AuditRebuildChainConnectionTestExpected: PASS. Затемcomposer pint && composer stan. -
Step 5: Commit
git add app/config/database.php app/app/Console/Commands/AuditRebuildChain.php app/tests/Feature/AuditRebuildChainConnectionTest.php
git commit -m "fix(audit): rebuild-chain через выделенный superuser-коннекшен (без ручного sudo)"
Self-Review (выполнено автором плана)
- Покрытие спеки assessment §4 (Путь Б): копии «на сторону» → Phase 1; реплика → Phase 3; отдельный сервер → Phase 4; починка аудита → Phase 5. ✅ Все 4 вехи покрыты.
- Деньги-инвариант проверяется в Task 0/2/5/6 (1 836 400.00 / 1013). ✅
- Прод-гейты проставлены на всех боевых шагах (
[PROD-GATE]+prod-deploy-validator). ✅ - Имена-консистентность: money-check запрос определяется один раз в Task 0 и переиспользуется;
S3_BUCKET/s3-backup.envединообразны во всех скриптах. ✅ - Открытый риск: точная таблица/колонка баланса подтверждается в Phase 0 (не выдумана) — Task 0 Step 1 это фиксирует. ✅