887abf444e
Получен 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>
884 lines
61 KiB
Markdown
884 lines
61 KiB
Markdown
# 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.
|
||
- **Спринт 14–15 (пост-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` каждые 15–30 мин.
|
||
|
||
**Что НЕ делать никогда:**
|
||
|
||
- Не выкатывать срочный 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).*
|