Files
portal/docs/Runbook_ekspluatatsii_v8_2.md
T
Дмитрий 887abf444e rebrand(v8.5→Лидерра): дизайн-handoff Платона v8 Forest + Лидпоток→Лидерра
Получен handoff-пакет liderra_v8_handoff/ от дизайнера Платона
(kpd9363@gmail.com) от 07.05.2026 — v8 Forest. Заказчик 08.05 решил
применить только в части дизайна, имени, логотипа. Функционал, состав
страниц и правила (CTO-11, click-wrap, SSO break-glass, 14 статусов
воронки) — без изменений (источник — ТЗ v8.5/schema v8.5).

Что сделано:

- Массовая замена Лидпоток→Лидерра (с учётом падежей: Лидерры/Лидерре)
  в 33 файлах (449 вхождений) — все .md/.sql/.json/.toml/.yml/.txt/.html,
  кроме исторических упоминаний внутри liderra_v8_handoff/
- Удалён docs/brandbook.md v1.1 — заменён на BRANDBOOK_v2.md из handoff
- Скопированы 13 концептов liderra_v8_handoff/concepts/v8_*.html в
  web/v8/. Удалены старые web/01-login.html, 02-dashboard.html,
  03-deals.html, index.html (палитра v1.1 deprecated)
- CLAUDE.md v1.0→v1.1: §0 (BRANDBOOK_v2 + DEVELOPER_HANDOFF в источниках),
  §2 (палитра Forest, Inter+JBM, Lucide), §5 п.6 (anti-pattern Inter
  снят — в Forest Inter наш основной шрифт), §6 (13 концептов в web/v8/)
- Реестр Открытые_вопросы_v8_3.md v1.12→v1.13: добавлена запись о
  ребрендинге + 4 точечных расхождений handoff vs ТЗ (статусы воронки,
  click-wrap чекбоксы, SSO fallback, axe violations)
- package.json/package-lock.json: name lidpotok→liderra

4 расхождения handoff vs ТЗ (НЕ применены, источник истины — ТЗ/schema):

1. 14 «обобщённых» статусов в BRANDBOOK_v2 §3.6 ≠ 14 slug'ов в
   schema.sql:2076 (совпадает 2 из 14: «Переговоры», «Оплачено»).
   Источник — schema/ТЗ §6.4 (реселлерская модель из аудита crm.bp-gr.ru,
   6 системных + 8 настраиваемых статусов).
2. 3-й click-wrap в v8_login.html («маркетинг-опционально») ≠ ТЗ §1.5/§4.1
   («согласие на ПДн», обязательное, OPEN-Ж-3).
3. SSO в v8_admin.html («локальный 2FA fallback») ≠ ТЗ OPEN-И-13
   (break-glass super_admin, локальный 2FA выключен).
4. Заявление «axe-core 4.10.2 — 0 violations» в README handoff — локально
   Pa11y 9.1.1 + axe нашёл 81 violation на 10/13 HTML (преимущественно
   color-contrast на декоративных separator'ах с --ink-disabled).
   Чисто: settings/errors/palette_options.

Что НЕ включено в коммит:
- лендинг/TZ_landing_v1_0.md — untracked, не моя работа в этой сессии
- .tmp/ — gitignored

Что осталось (для следующих сессий):
- Возможное переименование GitHub-репо CoralMinister/lidpotok → liderra
  (отдельное решение заказчика)
- Опционально: обратная связь Платону по 4 расхождениям handoff vs ТЗ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 07:11:58 +03:00

884 lines
61 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.
# Runbook эксплуатации — v8.2, Приложение И
**Версия:** 8.2 от 04.05.2026 (правки после интервью с заказчиком).
## Что нового в v8.2 относительно v8.1
-**OPEN-И-1 закрыт.** Таблица `incidents_log` создана (DDL — в Прил. Д v8.2 «Что нового»). Все типовые инциденты этого runbook должны теперь записываться в `incidents_log` через `INSERT` или через хелпер `IncidentLogger::open(type, severity, summary)` в коде. Формат: `started_at` = первая зафиксированная аномалия, `detected_at` = время алерта или ручного обнаружения, `resolved_at` = NULL до закрытия инцидента, потом проставляется. После закрытия — обязательно заполнить `root_cause` и `postmortem_url`.
-**OPEN-И-2 закрыт.** Стратегия CRM-интеграций:
- **На MVP**: только outbound webhook (`webhook_endpoints` таблица, базовая отправка с retry — уже в плане).
- **Архитектурный задел**: интерфейс `CrmConnectorInterface` в коде, пустая таблица `crm_connections` (поля: `id, tenant_id, provider, oauth_token, settings JSONB, last_sync_at, status, created_at`). Без UI.
- **Спринт 1415 (пост-MVP-frontend, перед public release)**: первый коннектор — **amoCRM**. OAuth-флоу, mapping полей, обработка лимитов API amoCRM (7 req/sec), retry с exponential backoff, тесты на sandbox.
- **Post-MVP по запросу клиентов**: Bitrix24, RetailCRM.
- 🔧 **OPEN-И-3..10, OPEN-И-12 продолжают ждать**: контакты on-call (`OPEN-И-12`, P1), шаблоны постмортемов, escalation timing — после Б-1 + DO-4.
> **Изменения 05.05.2026 (v8.3.1):** разрешена коллизия `OPEN-И-2`. Запись «контакты эскалации» (была `OPEN-И-2` в v8.1) переименована в `OPEN-И-12` (см. таблицу В.1). Текущий смысл `OPEN-И-2` — стратегия CRM-интеграций (✅ закрыт). `OPEN-И-11` — миграции `crm_connections`/`crm_field_mappings` (✅ закрыт). `OPEN-И-12` — контакты эскалации (⏸ P1).
- 🔁 **Обновление под Yandex Cloud (DO-1):** все диагностические команды в разделах ниже изначально были написаны абстрактно (`pg_isready`, `redis-cli`). После закрытия DO-1 в v8.4 будут уточнены под `yc managed-postgresql cluster get`, `yc managed-redis cluster get` и аналоги. Сейчас абстрактные команды — корректны, но в spirit Yandex CLI.
**Назначение документа:** закрыть пробел 🟡 №11 из конспекта анализа v8.0 — «нет runbook эксплуатации, что делать дежурному при инцидентах». Документ — практическое руководство для on-call дежурного: типовые инциденты, диагностика, шаги восстановления, эскалация. Учитывает все архитектурные решения v8.1 (RLS, реселлерская модель, chargeback, impersonation, новые cron'ы CTO-1/CTO-3).
**Целевая аудитория:**
- **Дежурный разработчик** (роль `dev_oncall`) — основной читатель;
- **Админ SaaS роли `super_admin` / `finance` / `compliance`** — для процедур, требующих действий через админку;
- **Тех. директор** — для процедур эскалации и пост-инцидентного разбора.
**Принципы документа:**
1. Каждая процедура — **5 секций**: симптом / алерт / диагностика / действия / эскалация. Лишнего нет.
2. **Никаких архитектурных объяснений** — для них есть главный файл v8.1. Здесь — только что нажать.
3. Команды и SQL — копируемые без правок. Все параметры в `<угловых скобках>` подставляются дежурным.
4. Уровни критичности соответствуют разделу 25.5 v8.1: **CRITICAL** (15 мин), **WARNING** (2 часа), **INFO** (best effort).
**Базовые ссылки на v8.1:**
- 23.5 — список cron-задач (всех 11)
- 23.6 — supervisor / воркеры очередей
- 23.7 — бэкапы PostgreSQL (WAL-G)
- 25 — мониторинг (Prometheus + Grafana, Sentry, healthcheck)
- 25.5 — каналы алертов и SLA
- 25.6 — on-call rotation
- 22.10 — безопасность платежей (HMAC, idempotence)
- 20.5 — жизненный цикл pending транзакций (CTO-3)
- 20.6.1 — chargeback workflow (Ю-3)
- Приложение В — state machines (для понимания валидных переходов)
- Приложение Г — админка SaaS (роли, экраны)
**Статус:** v0.1 от 04.05.2026, готов к использованию с момента запуска staging. Будет дополняться по мере накопления опыта эксплуатации (раздел И.13 — журнал прецедентов).
---
## 0. Содержание
1. Подготовка дежурного (что должно быть под рукой)
2. Общий алгоритм при срабатывании любого алерта
3. Webhook от crm.bp-gr.ru: пропал поток лидов
4. Очереди: backlog растёт, воркеры не справляются
5. Платежи: late_webhook_ignored / зависшие pending / шлюз не отвечает
6. Tariffs:apply-scheduled не отработал в 00:00 МСК
7. RLS-политика: подозрение на утечку между тенантами
8. Chargeback unrecovered: алерт finance
9. БД: сбой репликации, восстановление, full disk
10. Бэкап тест-восстановления упал
11. Истечение TLS-сертификата / DNS-инцидент
12. Эскалация: куда, кому, как
13. Журнал прецедентов (для пополнения)
---
## 1. Подготовка дежурного
При получении смены дежурства убедиться, что есть доступ ко всему ниже. Если чего-то нет — **до начала смены** запросить у тех. директора, **не во время инцидента**.
| Ресурс | Адрес | Доступ |
|---|---|---|
| Sentry | `https://sentry.<домен>` или SaaS | Логин по SSO |
| Grafana | `https://grafana.<домен>` | Логин по SSO + роль `editor` |
| Alertmanager | `https://alertmanager.<домен>` | Чтение + silence |
| Bastion / SSH | `bastion.<домен>` | SSH-ключ дежурного |
| Production DB (read-only) | `postgres-ro.<домен>` | Через bastion + ключ + пароль роли `crm_oncall_readonly` |
| Production DB (write, через миграции) | `postgres-master.<домен>` | **Только через PR + четыре глаза**, не напрямую |
| Админка SaaS | `https://admin.<домен>` | Роль `dev_oncall` + 2FA |
| Status page (управление) | `https://status.<домен>/admin` | Логин редактора |
| Slack `#alerts`, `#oncall`, `#sre` | — | Член этих каналов |
| Контакты эскалации | Этот Runbook, раздел 12 | — |
**Минимальная проверка работоспособности дежурства** (сделать в первый день смены):
```bash
# Из своей машины через bastion
ssh bastion.<домен>
ssh app-01.<домен> # должно пройти
psql "postgresql://crm_oncall_readonly@postgres-ro/crm" -c "SELECT 1" # должно вернуть 1
curl -s https://<домен>/health | jq .status # должно вернуть "healthy"
```
Если хоть одна команда не работает — это уже инцидент дежурства, фиксировать в `#oncall` сразу.
---
## 2. Общий алгоритм при срабатывании любого алерта
Прежде чем кидаться чинить — **5 шагов за 60 секунд**:
1. **Подтвердить получение** в Slack `#alerts` реакцией 👀 (чтобы команда знала что взято в работу).
2. **Открыть Grafana** → дашборд «SaaS overview» → проверить, есть ли смежные алерты или это одиночный.
3. **Открыть Sentry** → отфильтровать по последним 30 минутам → есть ли спайк ошибок.
4. **Проверить Status page**: в плановых работах ли это (если да — отбой).
5. **Решить**: это инцидент CRITICAL (продакшн лежит) или WARNING (что-то деградирует, но работает)?
Если **CRITICAL** → сразу:
- стартовать тред в `#oncall` с заголовком `🚨 INC-YYYYMMDD-HHMM <короткое описание>`;
- создать запись в `incidents_log` (или Notion/Confluence до момента когда таблица появится — `[OPEN-И-1]`);
- эскалация по разделу 12 параллельно с действиями;
- НЕ откладывать публичный апдейт на Status page больше чем на 10 минут — пользователи уже видят проблему.
Если **WARNING** → можно работать в обычном темпе, но фиксировать ход в `#oncall` каждые 1530 мин.
**Что НЕ делать никогда:**
- Не выкатывать срочный hotfix в production без PR и хотя бы одного reviewer'а. Лучше потерять 10 минут на review, чем уронить вторую систему.
- Не делать `DELETE`, `UPDATE`, `TRUNCATE` напрямую в production-БД. Это **всегда** через миграцию + PR + four-eyes (даже в инциденте — если очень нужно, эскалировать на тех. директора и делать совместно).
- Не отключать RLS-политики «временно для отладки». Это создаёт окно утечки. Использовать роль `crm_admin_user` (BYPASSRLS) только для конкретного запроса в read-only.
- Не пушить в master напрямую. Все инцидентные правки — через PR с тегом `incident/INC-XXX`.
---
## 3. Webhook от crm.bp-gr.ru: пропал поток лидов
**Симптом:** алерт `WebhookEndpointDown` (rate(webhook_received_total[10m]) == 0 в течение 30 минут). Может также проявиться как жалобы тенантов в `#support`: «лиды перестали приходить».
**Уровень:** CRITICAL — это наш единственный источник дохода (Ю-2). Каждый час простоя = реальные потерянные лиды.
### 3.1. Диагностика (по порядку)
```bash
# А. Проверить, что наш endpoint вообще отвечает
curl -X POST https://<домен>/webhooks/leads \
-H "Content-Type: application/json" \
-d '{"test": true}' \
-w "%{http_code}\n"
# Ожидаем: 401 (unsigned) или 400 (invalid payload) — endpoint жив
# Если 502/503/504 → проблема на нашей стороне (раздел 3.2 А)
# Если 200 на тестовый payload → у нас проблема с валидацией (раздел 3.2 Б)
```
```sql
-- Б. Посмотреть последние записи webhook_log
SELECT received_at, source_ip, COUNT(*)
FROM webhook_log
WHERE received_at > NOW() - INTERVAL '2 hours'
GROUP BY 1, 2
ORDER BY 1 DESC LIMIT 50;
-- Если последняя запись > 30 минут назад → либо crm.bp-gr.ru не шлёт, либо мы не получаем
```
```bash
# В. Проверить failed_webhook_jobs
psql ... -c "SELECT failure_reason, COUNT(*) FROM failed_webhook_jobs WHERE created_at > NOW() - INTERVAL '1 hour' GROUP BY 1;"
# Если много failures с одной причиной (например, signature_invalid) → раздел 3.2 В
```
### 3.2. Действия
**А. Endpoint не отвечает (502/503/504):**
1. Проверить `kubectl get pods` (или `supervisorctl status` на VM): жив ли application-pod / php-fpm.
2. Проверить Nginx logs: `tail -100 /var/log/nginx/error.log` — есть ли upstream timeouts.
3. Если pod умер — рестарт через `kubectl rollout restart deployment/app` или `supervisorctl restart all`.
4. Если Nginx падает — перезапустить Nginx: `systemctl restart nginx`.
**Б. Endpoint отвечает 200, но лиды не идут:**
1. Возможно, crm.bp-gr.ru сам перестал слать. Связаться с их поддержкой по контакту из договора (закреплён в Б-1).
2. Параллельно — проверить, нет ли у них на стороне нашего IP в чёрном списке (могло быть после серии 5xx с нашей стороны ранее).
3. Записать в `#oncall` факт обращения к crm.bp-gr.ru с временем.
**В. Webhook'и идут, но падают на валидации:**
1. Если `failure_reason='signature_invalid'` массово → возможно crm.bp-gr.ru обновили секрет HMAC. Запросить у них новый, обновить в `system_settings.webhook_hmac_secret` через миграцию + PR.
2. Если `failure_reason='tenant_not_found'` → их payload содержит `external_project_id`, которого у нас нет. Возможно тенант удалил проект, а crm.bp-gr.ru ещё шлёт. Это **штатная ситуация** — лид отбрасывается, не алерт.
3. Если `failure_reason='balance_insufficient'` массово (от одного тенанта) → этот тенант исчерпал баланс на тарифе `start`. Это тоже штатно.
### 3.3. Эскалация
- Если в течение **30 минут** не понятно, проблема у нас или у crm.bp-gr.ru — эскалировать тех. директору.
- Если crm.bp-gr.ru не отвечает на запрос **2 часа** — эскалировать на бизнес-владельца (есть ли SLA в договоре, как принудить).
- Параллельно — обновить Status page: «Деградация приёма лидов, идёт диагностика».
### 3.4. После восстановления
- Записать в `#sre` пост-мортем: что было, сколько лидов потеряно (по данным `webhook_log` диффом), что сделано чтобы не повторилось.
- Если лиды массово потеряны — обсудить с finance компенсацию пострадавшим тенантам (ручное начисление через `balance_transactions(type='manual_adjustment')`, требует four-eyes если > 50 000 ₽).
---
## 4. Очереди: backlog растёт, воркеры не справляются
**Симптом:** алерт `QueueBacklog` (queue_jobs_pending > 10000). Может также проявиться через Horizon dashboard — в Grafana панель «Queue length».
**Уровень:** WARNING (если очередь `webhooks`) → быстро становится CRITICAL, так как лиды стоят.
### 4.1. Диагностика
```bash
# Какие очереди заполнены
redis-cli LLEN queues:webhooks
redis-cli LLEN queues:default
redis-cli LLEN queues:reports
redis-cli LLEN queues:emails
# Сколько воркеров живо
supervisorctl status | grep crm
# или в k8s:
kubectl get pods -l app=crm-worker
```
```sql
-- Что в failed_jobs за последний час
SELECT queue, exception, COUNT(*)
FROM failed_jobs
WHERE failed_at > NOW() - INTERVAL '1 hour'
GROUP BY 1, 2
ORDER BY 3 DESC;
```
### 4.2. Действия
**А. Воркеры мертвы:**
1. `supervisorctl restart crm-webhook-worker:*` (или k8s rollout restart).
2. Подтвердить через Horizon, что воркеры подцепили задачи.
3. Backlog должен начать снижаться. Если нет — раздел 4.2 В.
**Б. Воркеры живы, но не успевают (нагрузочный пик):**
1. Временно увеличить numprocs в `/etc/supervisor/conf.d/crm-workers.conf` с 4 до 8–12 (для очереди `webhooks`).
2. `supervisorctl reread && supervisorctl update`.
3. Следить за Grafana: если CPU app-серверов > 80% — нужно горизонтально масштабироваться, эскалация на DevOps.
**В. Воркеры подбирают, но job-ы массово падают:**
1. Посмотреть `failed_jobs.exception` — обычно одна причина: ошибка в коде, недоступная зависимость (Sentry/email/S3), миграция не применена, RLS-политика блокирует.
2. Если ошибка явно от свежего деплоя — **rollback**: `kubectl rollout undo` или `git revert <sha> && deploy`.
3. Если ошибка от внешнего сервиса (Sentry/SMTP timeout) — это нормально для retry; если массово — отключить временно зависимость (например, переключить SMTP на резервного провайдера).
**Г. Очередь `failed_webhook_jobs` (особый случай):**
- Это не очередь Redis, а таблица. Туда попадают webhook'и после 3 неудачных попыток.
- Содержимое анализируется через админку (раздел «Очереди и инциденты», см. Приложение Г).
- Ручной retry — через UI «Перезапустить» (роль `dev_oncall`).
- Массовый retry — через `php artisan webhook:retry-failed --since="2 hours ago"`.
### 4.3. Эскалация
- Backlog `webhooks` > 10 000 в течение **30 минут** → CRITICAL, эскалация тех. директору.
- Backlog `reports` или `emails` > 10 000 — WARNING, можно дождаться рабочего времени.
---
## 5. Платежи: late_webhook_ignored / зависшие pending / шлюз не отвечает
**Симптом 1:** алерт `finance` в админке (раздел 20.5 v8.1) — late_webhook_ignored, ручной разбор.
**Симптом 2:** жалобы тенантов «оплатил, но баланс не пополнился».
**Симптом 3:** алерт от Alertmanager на rate ошибок к платёжному шлюзу.
**Уровень:** WARNING (одиночный случай) → CRITICAL если массовый сбой шлюза.
### 5.1. `late_webhook_ignored` — ручной разбор одного случая
Контекст (CTO-3): транзакция `failed`, поздно прилетел webhook `payment.succeeded`. Переход `failed → success` запрещён, статус не меняется, в админке алерт.
**Шаги для finance / dev_oncall:**
1. Открыть админку → раздел «Биллинг» → подвкладка «Аномалии платежей» → найти запись.
2. Посмотреть payload позднего webhook: действительно ли деньги списались у клиента?
3. **Если да** (деньги ушли клиенту):
- Вариант 1: ручное начисление через `balance_transactions(type='manual_adjustment')` в админке. С four-eyes если > 50 000 ₽ (ДЕФ-4).
- Вариант 2: возврат через шлюз — если клиент не хочет лиды, попросить finance вернуть деньги.
- В обоих случаях — комментарий в `saas_admin_audit_log` с ссылкой на `transaction_id` и обоснованием.
4. **Если нет** (deal flagged как `failed` правильно, payload подозрительный):
- Связаться с поддержкой шлюза (ЮKassa / Tinkoff) с `idempotence_key` и `transaction_id`.
- Не начислять, пока не подтверждено.
### 5.2. Зависшие pending старше 30 минут массово
**Симптом:** растёт счётчик pending транзакций в Grafana, cron `payments:cancel-stale` отработал, но не справляется.
```sql
SELECT
gateway,
COUNT(*) FILTER (WHERE created_at < NOW() - INTERVAL '30 minutes') AS stale_30min,
COUNT(*) FILTER (WHERE created_at < NOW() - INTERVAL '24 hours') AS stale_hard
FROM saas_transactions
WHERE status = 'pending'
GROUP BY 1;
```
- Если `stale_hard > 0` — это нарушение CTO-3, не должно быть. Расследовать почему cron не сработал (раздел 4 — очереди или раздел 6 — cron).
- Если только `stale_30min > 100` — возможно, шлюз медленный. Проверить статус ЮKassa: `https://yookassa.ru/status` или Tinkoff аналогично. При деградации шлюза — это **их** проблема, мы только мониторим и алертим клиентов.
### 5.3. Шлюз полностью не отвечает
1. Проверить публичный статус шлюза.
2. Если шлюз down — переключиться на резервного через `system_settings.payments_default_gateway` (если есть driver-альтернатива; иначе ждать).
3. Status page: «Платежи через ЮKassa временно недоступны, остатки баланса работают штатно».
4. Не давать обещаний клиентам по таймингу — это от шлюза.
### 5.4. Эскалация
- Любая проблема с биллингом затрагивающая >5 тенантов → эскалация на роль `finance` SaaS-админки + тех. директор.
---
## 6. Tariffs:apply-scheduled не отработал в 00:00 МСК
**Симптом:** утром обнаруживается, что подписки, у которых `started_at = вчерашние 00:00 МСК`, всё ещё в статусе `scheduled`. Тенанты пишут в поддержку «я сменил тариф вчера, а изменений нет».
**Уровень:** WARNING — биллинг временно деградировал, но данные корректны.
### 6.1. Диагностика
```sql
-- Сколько scheduled-подписок зависло
SELECT COUNT(*), MIN(started_at), MAX(started_at)
FROM tariff_subscriptions
WHERE status = 'scheduled'
AND started_at <= NOW();
```
```bash
# Посмотреть последний запуск cron
grep "tariffs:apply-scheduled" /var/log/laravel.log | tail -20
# Или через Horizon — там видно историю schedule
```
### 6.2. Действия
1. **Если cron вообще не запускался** (например, system cron упал, или supervisor с воркером):
- Проверить `crontab -l -u www-data`: есть ли строка `* * * * * php artisan schedule:run`.
- Перезапустить supervisor: `supervisorctl restart crm-default-worker:*`.
- Запустить вручную: `php artisan tariffs:apply-scheduled`.
2. **Если cron запускался, но падал**:
- Sentry → искать события от `App\Console\Commands\TariffsApplyScheduled`.
- Типичная причина: race condition (одна `active` + одна `scheduled` нарушены — но это ловят уникальные частичные индексы, должно быть исключение).
- Если индекс нарушен — это критичный баг, эскалация в разработку, **не fixить руками в production-БД**.
3. **Принудительный прогон:**
```bash
php artisan tariffs:apply-scheduled --force --verbose
```
Если несколько подписок не прошли валидацию — Laravel напишет какие, дальше — точечный разбор.
### 6.3. Эскалация
Если непонятно почему cron упал — эскалация в разработку (рабочее время) или тех. директору (нерабочее время).
---
## 7. RLS-политика: подозрение на утечку между тенантами
**Симптом:** клиент пишет в поддержку «я вижу чужого лида / чужие транзакции / чужие данные». Или тест `assertTenantIsolation` упал на CI после миграции.
**Уровень:** CRITICAL **всегда**, без исключений. Даже подозрение — это инцидент. Это удар по 152-ФЗ и репутации.
### 7.1. Действия (НЕМЕДЛЕННО)
1. **Подтвердить получение** жалобы клиента, **попросить скриншот** (если ПДн на нём — попросить замазать). Не спорить, не объяснять.
2. **Зафиксировать** в `#oncall` как `🚨 INC-YYYYMMDD-HHMM Suspected RLS leak`.
3. **Эскалация СРАЗУ** — тех. директор + `compliance` + `super_admin`. Не ждать диагностики.
4. **Status page: НЕ обновлять** до понимания причины (преждевременная публикация = паника).
### 7.2. Диагностика (только тех. директор + compliance)
```sql
-- Проверить, какая роль БД использовалась в подозрительном запросе
-- Лог приложения должен содержать запись с user_id и tenant_id
-- Найти соответствующий запрос в pg_stat_activity или в логе query_log
-- Проверить, что RLS включён на всех 30 таблицах
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND tablename IN (SELECT tablename FROM <список 30 таблиц>);
-- rowsecurity должно быть true для всех
-- Проверить, что политики не были случайно дропнуты
SELECT schemaname, tablename, policyname
FROM pg_policies
ORDER BY 1, 2;
-- Должно быть 29 политик
```
### 7.3. Сценарии
**Сценарий А: миграция выключила RLS на таблице.**
Например, `ALTER TABLE ... DISABLE ROW LEVEL SECURITY;` где-то проскочил. Линтер моделей на CI должен был отловить — если не отловил, это второй баг.
- Немедленно: `ALTER TABLE ... ENABLE ROW LEVEL SECURITY;` через миграцию (ускоренный путь — тех. директор пушит в master, миграция деплоится, факт логируется).
- Дальше: full audit таблицы (диффом по `pd_processing_log` за период когда RLS был выключен) — могла ли быть утечка кому-то ещё.
**Сценарий Б: `crm_app_user` каким-то образом получил BYPASSRLS.**
- Срочно: `ALTER ROLE crm_app_user NOBYPASSRLS;`. Расследование — кто и когда дал привилегию.
**Сценарий В: код приложения забыл `SET LOCAL app.current_tenant_id`.**
- Тогда RLS политика сработала, но `current_setting('app.current_tenant_id')` вернул '' или NULL — поведение зависит от политики.
- Проверить: какие запросы шли БЕЗ middleware tenant-scoped (например, обработчик webhook до резолва тенанта, или job без `TenantAwareJob`-родителя).
**Сценарий Г: Данные технически правильные, но клиент видит то что НЕ его (визуальный баг во фронте).**
- Это не RLS — это код фронтенда показывает кэш чужого пользователя.
- Фикс на фронте, но аудит на бэке всё равно нужен.
### 7.4. После
- Уведомить ВСЕХ тенантов которые могли пострадать (по `pd_processing_log`).
- Уведомление в Роскомнадзор об инциденте — ст. 21 ч. 3.1 ФЗ-152, в течение 24 часов о факте, в течение 72 часов — о результатах внутреннего расследования. Шаблон уведомления — задача юриста (подача через `compliance`-роль).
- Пост-мортем в `#sre`.
> 📝 Этот тип инцидента требует обязательной фиксации в `incidents_log` (`[OPEN-И-1]` если таблицы ещё нет) с пометкой `pd_breach=true`. Эта пометка триггерит специальные процедуры compliance.
---
## 8. Chargeback unrecovered: алерт finance
**Симптом:** алерт finance в админке: «Chargeback по тенанту X, сумма Y ₽, не покрыт балансом на Z ₽, тенант приостановлен» (раздел 20.6.1 v8.1).
**Уровень:** WARNING — финансовый, не технический. Тенант уже автоматически приостановлен системой.
### 8.1. Действия (роль `finance`)
1. Открыть админку → «Биллинг» → «Chargeback» → найти запись.
2. Посмотреть детали:
- Сумма chargeback;
- Дата исходного платежа;
- Какой это тенант — давний клиент / новый / подозрительный?
3. Решение принимает finance, но варианты:
**Вариант А — реальный спор клиента:**
- Связаться с клиентом, выяснить причину.
- Если конструктивно — попросить отозвать chargeback (через банк). После отзыва — `chargeback_unrecovered_rub` обнулить через `manual_adjustment`, статус восстановить.
- Если клиент не идёт на контакт — оставить тенант в `suspended`, ждать.
**Вариант Б — мошенничество / техническая ошибка:**
- Списать долг как убыток через `balance_transactions(type='manual_adjustment')` с обнулением `chargeback_unrecovered_rub`.
- **Four-eyes обязателен** если сумма > 50 000 ₽ (ДЕФ-4).
- Запись в `saas_admin_audit_log` с обоснованием.
**Вариант В — клиент готов погасить:**
- Клиент через `/billing` оплачивает (тип транзакции `chargeback_repayment`).
- После успеха — автоматически: `chargeback_unrecovered_rub=0`, статус `active` (если не было других причин suspended).
### 8.2. Эскалация
- Сумма > 100 000 ₽ — эскалация бизнес-владельцу (нужно решение «списываем или принципиально взыскиваем»).
- Систематические chargeback от одного шлюза → проверить настройки HMAC и idempotence keys, возможно технический баг с нашей стороны.
---
## 9. БД: сбой репликации, восстановление, full disk
### 9.1. Реплика отстаёт > 30 секунд
**Алерт:** `PostgresReplicationLag` (lag_seconds > 30).
```sql
-- На master:
SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn,
(extract(epoch from (now() - reply_time)))::int AS lag_sec
FROM pg_stat_replication;
```
- Если lag растёт — реплика медленнее master. Возможные причины: тяжёлый запрос на реплике (`SELECT ... FROM huge_table` от аналитики), сеть, диск.
- Действия: убить тяжёлый запрос на реплике (`pg_terminate_backend(pid)`), проверить i/o (iostat).
- Если сеть — эскалация DevOps.
### 9.2. Master упал
**Алерт:** `DatabaseDown` (up{job="postgres"} == 0).
**CRITICAL.** Действия:
1. Подтвердить, что master действительно недоступен (попробовать `pg_isready` через bastion).
2. Эскалация ТД + DevOps **немедленно**.
3. **Не делать failover самому** — это решение DevOps + ТД совместно. Failover требует:
- убедиться что master действительно мёртв (а не split-brain);
- выбрать реплику с минимальным lag;
- переключить master/standby роли;
- переключить connection string в `DATABASE_URL` (через secret-manager) — приложение само переподключится;
- убедиться что старый master не вернулся как «второй master» (split-brain — проверить etcd/Patroni если используется).
4. Status page: «БД недоступна, идёт восстановление».
### 9.3. Full disk на master
**Алерт:** `DiskSpaceLow` (disk_free / disk_total < 0.1).
1. Что занимает место — обычно WAL-сегменты или партиции старых таблиц.
2. Срочно: убедиться что WAL-G успешно архивирует — иначе WAL накапливается.
```bash
ls -la /var/lib/postgresql/16/main/pg_wal | wc -l
# Если > 100 — что-то сломалось в архивировании
```
3. Если архивирование работает — старые WAL должны самоудаляться. Проверить настройку `archive_command`.
4. Если действительно полно `pg_wal` — это уже близко к падению, эскалация DevOps **сейчас**.
5. **Никогда не удалять WAL вручную** — это убьёт PITR.
### 9.4. Эскалация
Любые проблемы с master БД — CRITICAL, ТД + DevOps сразу.
---
## 10. Бэкап тест-восстановления упал
**Симптом:** алерт «❌ КРИТИЧНО: бэкап сломан» в Slack `#alerts` от cron `backup:test-restore` (раздел 23.7.2 v8.1).
**Уровень:** WARNING (один прогон) → CRITICAL (два подряд).
### 10.1. Диагностика
1. Открыть лог последнего прогона (где сохраняется — DevOps определяет в инфра-конфиге).
2. Smoke-test упал на каком этапе?
- **Восстановление WAL** — проверить, не corrupted ли последние сегменты в S3.
- **Старт PostgreSQL** на тестовом — обычно из-за несовместимости версий.
- **Тестовый запрос** — неожиданно, скорее всего реальная битая БД.
### 10.2. Действия
1. Запустить вручную: `php artisan backup:test-restore --verbose`.
2. Если повторно падает — эскалация DevOps.
3. **Параллельно** — сделать дополнительный полный снимок прямо сейчас (через WAL-G) на случай, если текущая ситуация нестабильна:
```bash
wal-g backup-push /var/lib/postgresql/16/main
```
4. Не считать инцидент закрытым, пока следующий регулярный test-restore не пройдёт ✅.
### 10.3. Эскалация
- 2 подряд упавших test-restore → CRITICAL, ТД + DevOps. Это означает что мы фактически **без бэкапа**.
---
## 11. Истечение TLS-сертификата / DNS-инцидент
### 11.1. Сертификат скоро истечёт
**Алерт:** `TLSCertExpiry` (certmanager.io метрика, обычно за 14 / 7 / 1 день).
- В Kubernetes — cert-manager обычно сам ротирует Let's Encrypt. Если не ротировал — посмотреть `kubectl describe certificate ...`.
- На VM — проверить cron `certbot renew`.
- Ручное обновление: `certbot renew --force-renewal -d <домен>`, потом `systemctl reload nginx`.
### 11.2. Сертификат уже истёк
**CRITICAL.** Браузеры показывают warning, клиенты не могут зайти.
1. Срочно — выпустить новый: `certbot --nginx -d <домен>`.
2. Если ACME challenge не работает (DNS, провайдер) — временно подложить самоподписанный + Status page предупреждение, эскалация DevOps.
### 11.3. DNS-инцидент
- Проверить, кто провайдер DNS (CloudFlare / route53 / DNS Made Easy).
- `dig +short <домен>` — что отдаёт?
- Если NS не отвечает — это у регистратора домена. Эскалация бизнес-владельцу (может потребоваться доступ к панели регистратора).
---
## 12. Эскалация
| Кто | Когда | Канал | Время доступа |
|---|---|---|---|
| **Дежурный → Тех. директор** | Любой CRITICAL > 15 минут без понимания причины. Любой подозреваемый RLS leak. Любой инцидент с БД-master. | Telegram + звонок | 24/7 (по on-call rotation тех. директора) |
| **Тех. директор → Бизнес-владелец** | Простой > 1 часа. Финансовый ущерб > 100 000 ₽. Решения о публичных коммуникациях / Status page при crisis. | Telegram + звонок | 24/7 |
| **Любой → DevOps** | Инфраструктурные проблемы (БД, K8s, сеть, сертификаты, DNS) | Slack `#sre` + Telegram | По on-call rotation DevOps |
| **Любой → `finance` SaaS-админ** | Платёжные инциденты, chargeback массово | Slack `#finance-ops` | Рабочее время; вне — best effort |
| **Любой → `compliance` SaaS-админ** | Подозрение на утечку ПДн. Обращение от Роскомнадзора. Массовые `pd_subject_requests`. | Slack `#compliance` | Рабочее время; вне — через тех. директора |
| **Тех. директор → Юрист** | Подтверждённый pd_breach. Уведомление в Роскомнадзор за 24/72 часа. | Email + звонок | По договорённости с юристом |
**Контакты — отдельный приватный документ** (`Контакты_эскалации.md`, доступен только on-call). Хранится в защищённом месте, не в публичном репозитории.
`[OPEN-И-12]` — где именно хранить контакты эскалации (1Password / Vault / Notion private). Решение DevOps + ТД.
> **⚠ Историческая справка.** В v8.1 этот вопрос имел ID `OPEN-И-2`. В v8.2 ID `OPEN-И-2` переиспользован для решения «Стратегия CRM-интеграций» (✅ закрыт 04.05.2026). Переименован в `OPEN-И-12` 05.05.2026 (v8.3.1) для разрешения коллизии. ID `OPEN-И-11` уже занят миграциями `crm_connections`/`crm_field_mappings` (✅ закрыт в v8.2 в составе OPEN-И-2).
---
## 13. Журнал прецедентов
> Этот раздел пополняется после каждого инцидента (post-mortem). Назначение — чтобы дежурный мог быстро найти «у нас уже было похожее, действовали так-то».
| Дата | INC-ID | Краткое описание | Уровень | Решение | Что улучшено в системе |
|---|---|---|---|---|---|
| *(пусто на момент v0.1)* | — | — | — | — | — |
> Пример заполнения после первого реального инцидента:
> | 15.06.2026 | INC-20260615-0342 | crm.bp-gr.ru сменили HMAC-секрет без уведомления | CRITICAL, 2 часа | Получили новый секрет, обновили `system_settings`, восстановили приём webhook | Добавлен алерт `HighSignatureFailureRate`, договорились с crm.bp-gr.ru о предуведомлении за 7 дней |
---
# ЧАСТЬ В. РАБОЧИЕ МАТЕРИАЛЫ
## В.1. Открытые вопросы для финализации
| ID | Вопрос | Кому | Приоритет | Влияние |
|---|---|---|---|---|
| ✅ ~~OPEN-И-1~~ | ~~Создать таблицу `incidents_log` для системного учёта инцидентов~~ | CTO | ~~P1~~ | **Закрыт 04.05.2026** в составе schema.sql v8.2 (см. шапку этого файла + `Прил_М_Analiz_originala_v8_3.md` §3.3, OPEN-Д-5/И-1) |
| **OPEN-И-12** | Где хранить контакты эскалации (1Password / Vault / Notion private) | DevOps + ТД | **P1** | Без этого раздел 12 не работоспособен на практике. *Дефолт (05.05): временно в приватном Notion / 1Password DevOps до закрытия Б-1 + DO-4.* Был `OPEN-И-2` в v8.1 — переименован 05.05 (v8.3.1), т.к. ID `OPEN-И-2` занят CRM-интеграциями, ID `OPEN-И-11` — миграциями `crm_connections`. |
| **OPEN-И-3** | Периодичность перевыпуска Runbook'а: пересматривать раз в квартал? | ТД | P2 | Без регламента документ застаревает |
| **OPEN-И-4** | Учения on-call: периодические game days с симуляцией инцидентов? | ТД + DevOps | P2 | Сильно повышает готовность; но требует ресурсов |
| **OPEN-И-5** | Шаблон уведомления Роскомнадзора об инциденте с ПДн (24/72 часа по ст. 21 ч. 3.1 ФЗ-152) | юрист | P1 | Без шаблона при инциденте теряется время |
| **OPEN-И-6** | Резервный платёжный шлюз — какой? Когда подключаем? | DevOps + бизнес | P2 | Сейчас единая точка отказа; раздел 5.3 предполагает его существование |
| **OPEN-И-7** | Procedure для массового compensating credit (когда нужно начислить N тенантам по факту простоя) | finance + CTO | P2 | Сейчас только ручное; требует кнопку в админке |
| **OPEN-И-8** | Регламент работы с Status page: кто публикует, через сколько после начала инцидента, как формулирует | ТД + бизнес | P1 | В разделе 2 сказано «не позже 10 минут», но нет шаблонов и ответственного |
| **OPEN-И-9** | Алерт `HighSignatureFailureRate` (раздел 13 example) — добавить в Alertmanager | DevOps | P2 | Конкретное улучшение, ловит сценарий 3.2 В быстрее |
| **OPEN-И-10** | Регламент пост-мортема: формат, сроки, кто пишет, кто ревью | ТД | P1 | Без регламента обучение из инцидентов не происходит |
## В.2. Что обновить в смежных документах
| Документ | Что меняется |
|---|---|
| `CRM_bp-gr_Инструкция_v8_1.md`, раздел 25.6 | Заменить упоминание «Runbook (документ с типовыми инцидентами и порядком действий)» на ссылку на это Приложение И |
| `CRM_bp-gr_Инструкция_v8_1.md`, раздел 28 | Добавить шифр И = `Runbook_ekspluatatsii_v8_1.md`. Финальный список приложений: А, Б, В, Г, Д, Е, Ж, З, И |
| `Открытые_вопросы_v8_1.md` | Закрыть 🟡 №11 (runbook эксплуатации); добавить OPEN-И-1..10 в раздел DevOps / эксплуатация |
| `Админка_SaaS_v8_1_драфт.md`, §4 (роли) | Уточнить полномочия роли `dev_oncall` со ссылкой на этот Runbook (что делает в каких сценариях) |
| `schema.sql` v8.2 | Добавить таблицу `incidents_log` (после OPEN-И-1) |
## В.3. Версионирование
| Версия | Дата | Изменения |
|---|---|---|
| v0.1 | 04.05.2026 | Первый Runbook в рамках сессии 03–04.05.2026, на основе архитектуры v8.1. Покрывает 9 типовых инцидентов |
| v0.2 (план) | После закрытия OPEN-И-1, И-2, И-5 | Структурные таблицы, шаблоны, контакты. Ввод в реальную работу с момента запуска staging |
| **v0.3** | **07.05.2026** | **Дополнения по реализации 27 решений аудита C (v1.12). Новые процедуры — см. Часть Г ниже** |
| v1.0 (план) | После 6 месяцев эксплуатации | Журнал прецедентов с реальными инцидентами; пересмотр процедур по итогам |
---
# Часть Г. v8.5 — процедуры по аудиту C (07.05.2026)
> **Источник:** реестр Открытые_вопросы_v8_3.md v1.12 §13.10. Реализация — schema.sql v8.5 (коммит `038a884`) + narrative v8.5 §22.13. Все процедуры ниже становятся частью обязательного operational baseline с момента деплоя v8.5 в staging.
## Г.1. CTO-13: RLS smoke-test через PgBouncer (обязательно в спринте 1)
**Когда:** перед первым PR, который использует `TenantAwareJob` или `SET LOCAL app.current_tenant_id`. Без прохождения теста — **триггер фазы 1 не открывается**.
**Что проверить:**
```sql
-- Кейс 1: auto-commit базовый
SET LOCAL app.current_tenant_id = 1;
SELECT COUNT(*) FROM deals WHERE tenant_id = 1; -- ожидание: успех
SELECT COUNT(*) FROM deals WHERE tenant_id = 2; -- ожидание: 0 (RLS блокирует)
-- Кейс 2: reuse соединения через PgBouncer (transaction-pooling)
-- В session 1: SET LOCAL → SELECT → COMMIT.
-- В session 2 (то же физ. соединение): SELECT без SET LOCAL.
-- Ожидание: либо exception (current_setting не установлен),
-- либо value сброшен (NULL/empty).
-- Молчаливое наследование значения от session 1 → BLOCKER.
-- Кейс 3: job retry
-- TenantAwareJob с tenant_id=1 падает с exception → retry.
-- При retry: SET LOCAL = 1 в начале handle().
-- Ожидание: tenant_id корректно установлен из payload, не из памяти worker'а.
-- Кейс 4: WITH CHECK защита (OPEN-И-14)
SET LOCAL app.current_tenant_id = 1;
INSERT INTO deal_tag_pivot (deal_id, tag_id) VALUES (1, <tag_id чужого тенанта>);
-- Ожидание: RLS exception на WITH CHECK.
-- Кейс 5: REVOKE на saas-таблицах (OPEN-И-14)
SET ROLE crm_app_user;
SELECT * FROM saas_admin_users LIMIT 1;
-- Ожидание: permission denied.
```
**Результат:** все 5 кейсов либо корректно изолируют tenant_id, либо явно падают с exception. Формальный отчёт — `docs/sprint1_rls_smoke.md` (создаётся в спринте 1) с актуальными PG/PgBouncer версиями.
## Г.2. OPEN-И-15: Cron `audit:verify-chain` (раз в сутки)
**Расписание:** `04:00 МСК` ежедневно. Низкий бизнес-трафик.
**Логика (псевдокод):**
```php
// app/Console/Commands/AuditVerifyChain.php
foreach (['auth_log','activity_log','pd_processing_log',
'saas_admin_audit_log','balance_transactions'] as $table) {
$lastChecked = Cache::get("audit_chain_last_id:$table", 0);
$rows = DB::table($table)->where('id','>',$lastChecked)
->orderBy('id')->get();
$prevHash = DB::table($table)->where('id','<=',$lastChecked)
->orderByDesc('id')->value('log_hash');
foreach ($rows as $row) {
$expected = hash('sha256', ($prevHash ?? '') . json_encode($row), true);
if ($expected !== $row->log_hash) {
Sentry::captureMessage("audit_chain_break: $table id={$row->id}", 'critical');
DB::table('incidents_log')->insert([/* type='audit_chain_break' */]);
}
$prevHash = $row->log_hash;
}
Cache::put("audit_chain_last_id:$table", $rows->last()?->id ?? $lastChecked);
}
```
**Performance:** инкрементально с `last_id`, полная проверка ~5 минут на 50М строк (одноразово при первой инициализации).
**При обнаружении break:**
1. Sentry severity=critical → email админам SaaS + OnCall.
2. `incidents_log` insert type='audit_chain_break', severity='high'.
3. **Не блокируем работу системы** — audit-таблицы продолжают писаться. Расследование — отдельный процесс с привлечением CTO.
## Г.3. OPEN-И-17: Cron `secrets:notify-expiring` (раз в сутки)
**Расписание:** `09:00 МСК` ежедневно.
**Логика:** SELECT api_keys WHERE `expires_at <= NOW() + INTERVAL '30 days' AND is_active=TRUE`. GROUP BY (`tenant_id`, `user_id`). Один email на группу в день. Subject: «Истекают API-ключи: {N} шт. через {3-30} дней». Тело — список с `key_prefix`, `expires_at`, deep-link на «Продлить».
**Действие пользователя:** клик → `/api-keys` → у каждого ключа кнопка «Продлить на год» (`expires_at = NOW() + INTERVAL '365 days'`). Аудит в `activity_log` event=`api_key.extended`.
**При истечении (`expires_at <= NOW()`):** ключ автоматически переходит в `is_active=FALSE` через cron `api_keys:deactivate-expired` (раз в час). Запросы с этим ключом → 401.
## Г.4. OPEN-И-21: Anti-DDoS компоненты
### Г.4.1. Nginx `limit_req_zone`
**Файл `nginx/conf.d/ratelimit.conf`:**
```nginx
limit_req_zone $binary_remote_addr zone=login:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=60r/s;
limit_req_zone global zone=global:1m rate=1000r/s;
server {
location /admin/login {
limit_req zone=login burst=5 nodelay;
limit_req zone=global;
}
location /api/v1/ {
limit_req zone=api burst=20 nodelay;
limit_req zone=global;
}
}
```
**Deployment:** через CI/CD job `deploy-nginx-config`. Перед applied — `nginx -t`. После — graceful reload.
### Г.4.2. Yandex SmartCaptcha
**Configuration:** `config/services.php` секция `yandex_captcha` — `client_key`, `server_key` (env, в Yandex Lockbox).
**Frontend:** Vue-компонент `<YandexCaptcha @verified="captchaToken = $event" />` оборачивает `<v-form>` страниц `/register`, `/login` (после 2 неудач), `/billing/topup`.
**Backend:** middleware `App\Http\Middleware\VerifyYandexCaptcha` проверяет `captcha_token` через POST к `https://captcha-api.yandex.ru/validate`. При неудаче → 429.
**Бюджет:** ~5 000 ₽/мес при 50К challenges (стандартный pricing 2026).
### Г.4.3. Disposable email blacklist
**Cron `accounts:refresh-disposable-list`** — раз в неделю (среда, 03:00 МСК):
```bash
curl -fSL https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf \
-o /var/www/liderra/storage/app/disposable-domains.txt
```
**Использование:** Laravel validation rule `App\Rules\NotDisposableEmail` на `RegisterController::register()`. Disposable email → ошибка валидации «Используйте корпоративный email».
## Г.5. OPEN-И-22: Per-tenant DEK + crypto-shred backup
**Архитектура:**
- **Yandex Cloud KMS** хранит per-tenant DEK (Data Encryption Key) AES-256.
- При создании tenant'а — `BackupService::createTenantDek($tenantId)` создаёт KMS key с tag `tenant_id=<id>`.
- Backup tenant'а: `pg_dump` → tarball → encryption envelope (random AES key + KMS-DEK) → upload в Object Storage `s3://liderra-backups/tenants/{id}/{date}.tar.enc + .envelope`.
- Restore: encrypted envelope → расшифровка через KMS-DEK → расшифровка tarball → pg_restore.
**Crypto-shred при удалении tenant'а** (после `tenants.deleted_at + 30 days`):
1. `BackupService::cryptoShred($tenantId)` → `yc kms key destroy --id <tenantDek>`.
2. Backup tarballs остаются в Object Storage, но без DEK расшифровать невозможно.
3. Через ещё 60 дней — `lifecycle policy` удаляет физически (defense-in-depth).
**Преимущество:** 152-ФЗ ст.21 «прекращение обработки» удовлетворяется криптографическим erasure — мгновенно и атомарно.
## Г.6. OPEN-И-24: pg_anonymizer для staging
**Цель:** безопасная репликация prod → staging без раскрытия ПДн (152-ФЗ ст.6).
**Расширение `pg_anonymizer`** ставится в фазе 3 (Прил. Н).
**Процедура `staging:refresh-from-prod`** (раз в неделю или по запросу):
```bash
# 1. Backup prod
pg_dump -Fc -h prod-pg.liderra.ru -U crm_admin_user liderra > prod-snap.dump
# 2. Restore в staging-db
pg_restore -h staging-pg.liderra.ru -U postgres -d liderra_staging prod-snap.dump
# 3. Apply masking
psql -h staging-pg.liderra.ru -d liderra_staging <<SQL
SELECT anon.start_dynamic_masking();
SECURITY LABEL FOR anon ON COLUMN users.email IS 'MASKED WITH FUNCTION anon.fake_email()';
SECURITY LABEL FOR anon ON COLUMN users.phone IS 'MASKED WITH FUNCTION anon.fake_phone()';
SECURITY LABEL FOR anon ON COLUMN users.totp_secret IS 'MASKED WITH VALUE NULL';
SECURITY LABEL FOR anon ON COLUMN deals.phone IS 'MASKED WITH FUNCTION anon.fake_phone()';
SECURITY LABEL FOR anon ON COLUMN deals.contact_name IS 'MASKED WITH FUNCTION anon.fake_first_name()';
SECURITY LABEL FOR anon ON COLUMN deals.comment IS 'MASKED WITH VALUE NULL';
-- ... аналогично для api_keys, saas_admin_users.
SELECT anon.anonymize_database();
SQL
```
**CI:** GitHub Actions job `staging-refresh` — раз в неделю по cron, либо `gh workflow run staging-refresh.yml` ручной trigger.
**Запрет:** прямые pg_dump prod → restore staging без anonymization — pre-commit hook + отказ деплоя при detection raw-prod-dump в staging.
## Г.7. OPEN-И-13: Yandex 360 SSO setup
**Шаги (для DevOps в фазе 1, спринт 11+):**
1. В Yandex 360 Admin Panel создать «приложение» типа OIDC. Получить `client_id` + `client_secret`.
2. Положить в Yandex Lockbox: `secret/yandex-sso/client-id`, `secret/yandex-sso/client-secret`.
3. `config/services.php` — секция `yandex360` (`client_id`, `client_secret`, `redirect_uri`, `authorize_url`, `token_url`, `userinfo_url`).
4. Routes: `Route::get('/admin/auth/yandex/callback', [SaasAdminAuthController::class, 'handleSsoCallback']);`.
5. Создать первый break-glass: `super_admin` с `sso_provider='local'`, `is_break_glass=TRUE`. Длинный random-пароль в 1Password DevOps vault. TOTP enrolled.
6. Тестовый OIDC-вход → проверка JIT-create + login.
7. `incidents_log` запись `event='yandex_sso_enabled'`.
**Откат:** при неработоспособности Yandex 360 — break-glass-аккаунт + временное переключение `is_break_glass=TRUE` для всех `super_admin` через прямой SQL под `crm_admin_user`. После восстановления IDP — обратно `FALSE`.
## Г.8. OPEN-И-25: Cron `leads:escalate-stale`
**Расписание:** каждые 30 мин (`*/30 * * * *`).
**Логика:**
```sql
SELECT id, tenant_id, project_id, manager_id, escalated_count
FROM deals
WHERE assigned_at IS NOT NULL
AND assigned_at + INTERVAL '4 hours' < NOW()
AND status NOT IN ('closed','rejected','spam')
AND escalated_count < 3
ORDER BY assigned_at ASC
LIMIT 100;
```
Для каждого:
1. `escalated_count++`.
2. Reassign: если `assignment_strategy='manual'` — supervisor проекта; если `round_robin`/`least_loaded` — следующий active member из `project_user_assignments`.
3. Email (см. §17.9.2 narrative) — старому + новому менеджеру + админу тенанта при `escalated_count >= 2`.
4. `activity_log` event=`deal.escalated`.
**Индекс:** `(tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected')` — для эффективного scan.
## Г.9. Биз-24: Cron `payments:notify-stale`
**Расписание:** раз в час (`0 * * * *`).
**Логика:** SELECT saas_invoices WHERE `status='waiting_payment' AND created_at + INTERVAL '48 hours' < NOW() AND notified_finance=FALSE`. Для каждого: email finance-роли + bell-нотификация в `/admin/billing/stale-invoices` + `notified_finance = TRUE`.
**Дополнительно** (default off): через 7 дней повторный алерт + создание `incidents_log` type='payment_overdue'.
**Конфигурация:** `system_settings` ключи `payments_notify_stale_threshold_hours`/`repeat_hours`/`enabled`.
---
*Драфт v0.3 от 07.05.2026. Часть Г добавлена в рамках реализации v8.5 (коммит `038a884` + narrative v8.5).*