diff --git a/cspell-words.txt b/cspell-words.txt index e79e96ea..fe4be9e8 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -463,6 +463,7 @@ slugs партиционированной партиционированием партиционирована +партиционированы Партнёрка виртуализация виртуализацией diff --git a/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md b/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md new file mode 100644 index 00000000..00dce611 --- /dev/null +++ b/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md @@ -0,0 +1,227 @@ +# ADR-018: Audit hash-chain semantics — per-tenant (через RLS scope) canonical + +- **Status:** Accepted +- **Date:** 2026-05-29 +- **Deciders:** User: Дмитрий (business policy 152-ФЗ) + +## Context + +Портал ведёт 6 append-only audit-таблиц с криптографической SHA-256 hash-chain +для tamper-detection (требование 152-ФЗ ст.18 ч.2): +`auth_log`, `activity_log`, `tenant_operations_log`, `pd_processing_log`, +`saas_admin_audit_log`, `balance_transactions`. Каждая запись содержит +`log_hash = sha256(prev_log_hash || ROW(...)::text)`, где `prev_log_hash` +берётся из последней предыдущей записи. UPDATE/DELETE заблокированы +триггером `audit_block_mutation` ([db/schema.sql:3134-3138](../../db/schema.sql#L3134)). + +Все 6 таблиц партиционированы по месяцам (RANGE по `created_at`). + +**Инцидент 29.05.2026** (`docs/incidents/2026-05-29-disk-full-pg-recovery.md`): +переполнение диска вызвало race condition в trigger `audit_chain_hash()` — +часть concurrent INSERT'ов создали ветвление цепочки. Был выпущен migration +`2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php` с +`pg_advisory_xact_lock`, race закрыт. Затем запущена команда `audit:rebuild-chain` +для пересчёта повреждённых партиций. + +После rebuild `audit:verify-chains` показал: + +- `balance_transactions_y2026_m05` — 0 mismatches ✅ +- `activity_log_y2026_m05` — 6 mismatches остаются (multi-tenant rows) + +При анализе обнаружилась несогласованность между тремя местами кода, которые +работают с цепочкой: + +| Место | Файл | Семантика | +|---|---|---| +| **Writer** (trigger) | [db/schema.sql:3107-3127](../../db/schema.sql#L3107) `audit_chain_hash()` | `SELECT log_hash FROM ORDER BY id DESC LIMIT 1` под RLS вставляющей сессии — **видит только rows своего tenant'а** (для tenant-таблиц), то есть фактически **per-tenant chain** | +| **Verify** | [app/app/Console/Commands/VerifyAuditChains.php:130-146](../../app/app/Console/Commands/VerifyAuditChains.php#L130) | `LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id)` — корректно воспроизводит per-tenant scope триггера | +| **Rebuild** | [app/app/Console/Commands/AuditRebuildChain.php:135-180](../../app/app/Console/Commands/AuditRebuildChain.php#L135) | `SET session_replication_role=replica` + global `ORDER BY id` без PARTITION BY — **не воспроизводит RLS scope**, делает global chain | + +Writer и Verify согласованы по per-tenant семантике (через RLS на стороне БД). +Rebuild делает global chain — это **bug**, потому что он запускается под +admin-сессией без RLS-контекста tenant'а и не воспроизводит реальную логику +триггера. 6 mismatches в `activity_log_y2026_m05` — следствие неправильного +rebuild'а, не оригинальной порчи. + +`saas_admin_audit_log` и `auth_log` пишутся всегда под BYPASSRLS-ролями +(saas-admin INSERT'ы / pre-auth INSERT'ы) — для них trigger даёт global chain +внутри партиции, и `VerifyAuditChains` использует `partition: ''` (без +PARTITION BY) — это согласовано, mismatches там нет. + +ADR-002 (multi-tenancy через PostgreSQL RLS) — основа: tenant-данные изолируются +по `tenant_id` через row-level security. Audit-цепочка наследует ту же +изоляцию автоматически, потому что SELECT в trigger подпадает под RLS. + +## Decision + +**Canonical semantics audit hash-chain — per-tenant внутри партиции** (через +RLS scope для tenant-таблиц, global для BYPASSRLS-таблиц), как уже работают +trigger (writer) и `VerifyAuditChains`. Команда `AuditRebuildChain` — +**bug**, должна быть переписана для воспроизведения per-tenant scope при +пересчёте. + +Конкретно: + +1. **Writer (trigger `audit_chain_hash()`) — без изменений.** Он уже даёт + правильную семантику автоматически через RLS scope. + +2. **Verify (`VerifyAuditChains::TABLE_CONFIG`) — без изменений.** Текущий + конфиг корректно отражает реальность: per-tenant для tenant-таблиц, + global для admin/auth-таблиц. + +3. **Rebuild (`AuditRebuildChain`) — переделать.** Команда должна обходить + партицию **per-partition-key** (то же `partition_clause` что в + `VerifyAuditChains::TABLE_CONFIG`): + - для `activity_log` / `tenant_operations_log` / `balance_transactions` / + `pd_processing_log` — отдельный rebuild для каждого `tenant_id`; + - для `saas_admin_audit_log` / `auth_log` — global rebuild как сейчас. + +4. **Очистка 6 mismatches в `activity_log_y2026_m05`** — после фикса + rebuild'а: re-run `audit:rebuild-chain --partition=activity_log_y2026_m05` + на dev → smoke → на проде. mismatches исчезнут (rebuild начнёт писать + ту же per-tenant логику что trigger). + +## Alternatives Considered + +### Alternative A: Per-tenant canonical (выбрано) + +Фиксируется как описано выше. Trigger и verify уже работают так — нужно +только починить rebuild. + +**Decision Maker's reasoning (Дмитрий):** «Закон о персональных данных +требует изолированность журналов клиентов. Простота кода — слабее +требование.» + +**Pros:** + +- Соответствует 152-ФЗ ст.18 — журналы tenant'ов изолированы. +- Cross-tenant tampering обнаружится: если кто-то полезет в БД руками + и подменит запись tenant'а A, цепочка tenant'а A треснет, цепочка + tenant'а B останется intact. +- Минимальные изменения: только rebuild переделать (полдня-день кода). +- Не требует миграции БД — existing rows уже правильные. +- 6 mismatches исчезнут автоматически после re-run исправленного rebuild'а. + +**Cons:** + +- Rebuild сложнее: нужен цикл по `DISTINCT tenant_id` с отдельной + prev-hash chain для каждого. + +### Alternative B: Global canonical (отклонено) + +Переписать trigger на `SECURITY DEFINER BYPASSRLS` чтобы он всегда видел все +rows партиции. Verify изменить — убрать `PARTITION BY tenant_id`. Rebuild +остаётся как сейчас (global). + +**User Feedback:** отклонено — ослабляет 152-ФЗ и требует рискованной миграции. + +**Pros:** + +- Код проще: один путь во всех трёх местах. +- Rebuild не трогаем. + +**Cons:** + +- 152-ФЗ слабее: один tenant теоретически (через будущий баг) может повлиять + на chain другого tenant'а. +- Требуется миграция: rebuild **всей** existing БД журналов под новую + логику. Высокий риск операции на проде. +- Триггер становится `SECURITY DEFINER` — повышает attack surface. + +### Alternative C: Do nothing + +Оставить 6 mismatches как known historical gap, документировать в README, +закрыть incident. + +**Pros:** + +- 0 работы. + +**Cons:** + +- Каждый запуск `audit:verify-chains` будет писать incident (best-effort + dedup 24ч смягчает, но не отменяет). +- Email-алёрты на `kdv1@bk.ru` каждый день после первого истекания dedup'а. +- При следующей аварии rebuild снова создаст новые mismatches — проблема + накапливается. +- Не закрывает архитектурную несогласованность: писатель и читатель + работают по одной логике, чинитель — по другой. + +## Consequences + +**Benefits** + +- 152-ФЗ tamper-detection работает по полной: per-tenant изоляция аудита. +- Все три места кода (writer / verify / rebuild) консистентны по + семантике после фикса. +- 6 mismatches в `activity_log_y2026_m05` исчезнут. +- Документирована causality между ADR-002 (RLS multi-tenancy) и + audit-chain semantics. + +**Trade-offs** + +- `AuditRebuildChain` усложняется: 50-100 LOC (цикл по tenant_id, per-tenant + prev-hash). +- Время rebuild'а партиции на много-tenant таблицах увеличивается + пропорционально числу tenant'ов (но rebuild — операция аварийного + восстановления, не hot path). + +**Risks and mitigations** + +- *Risk:* в `AuditRebuildChain` появятся пограничные случаи (tenant_id IS + NULL, single-tenant rows). *Mitigation:* TDD-тесты на каждый шаблон — + pure-tenant / mixed-tenant / single-row партиции; покрытие в + `AuditRebuildChainTest.php`. +- *Risk:* `auth_log` (BYPASSRLS, global) — rebuild должен явно различать + global vs per-tenant tables. *Mitigation:* читать `partition_clause` из + shared конфига (extract из `VerifyAuditChains::TABLE_CONFIG` в общий + helper), не дублировать список. +- *Risk:* при будущем добавлении 7-й audit-таблицы — забыть указать + partition_clause. *Mitigation:* shared `AuditChainConfig::TABLES` constant + - assertion в `VerifyAuditChains::handle()` что все 6 таблиц зарегистрированы. + +## Related Decisions + +- **ADR-002 (Multi-tenancy через PostgreSQL RLS)** — основа: RLS scope, через + который trigger автоматически получает per-tenant chain semantics для + tenant-таблиц. +- **Incident 2026-05-29 disk-full PG recovery** — + `docs/incidents/2026-05-29-disk-full-pg-recovery.md` — контекст обнаружения + расхождения. +- **F1 advisory-lock migration** — + `app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php` + закрывает race condition между concurrent INSERT'ами; работает в любой + семантике (global или per-tenant), потому что lock ставится по + `(TG_TABLE_NAME, tenant_id)`-ключу. + +## References + +- [db/schema.sql:3107-3127](../../db/schema.sql#L3107) — `audit_chain_hash()` trigger function +- [db/schema.sql:3148-3188](../../db/schema.sql#L3148) — 6 пар триггеров (BEFORE INSERT + UPDATE/DELETE block) +- [app/app/Console/Commands/VerifyAuditChains.php](../../app/app/Console/Commands/VerifyAuditChains.php) — verify command (per-tenant + global) +- [app/app/Console/Commands/AuditRebuildChain.php](../../app/app/Console/Commands/AuditRebuildChain.php) — rebuild command (bug: global only) +- [app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php](../../app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php) — F1 advisory-lock +- Memory: `memory/feedback_audit_chain_algorithm_divergence.md` — устаревшая трактовка как «divergence design'а», скорректировано в этом ADR как bug rebuild'а +- 152-ФЗ ст.18 ч.2 — требование фиксации операций обработки ПДн +- Stage 5 follow-up plan — будет создан под реализацию решения (TBD после Stage 5 batch-переключения) + +## Enforcement + +```json +{ + "rules": [ + { + "id": "rebuild-must-use-shared-config", + "description": "AuditRebuildChain должна читать partition_clause из общего конфига, не определять semantics локально", + "applies_to": ["app/app/Console/Commands/AuditRebuildChain.php"], + "require_pattern": "AuditChainConfig::TABLES|partition_clause" + } + ], + "llm_judge": false +} +``` + +Декларативное правило сработает после реализации фикса rebuild'а (Stage 5 +follow-up). До реализации rule в `Proposed` неактивен — этот ADR-018 +зафиксирован как `Accepted` потому что **архитектурное решение** принято; +имплементация — отдельная задача.