docs(adr): ADR-018 audit hash-chain per-tenant semantics canonical

29.05 disk-full incident выявил несогласованность между trigger (per-tenant
через RLS), VerifyAuditChains (per-tenant через PARTITION BY tenant_id) и
AuditRebuildChain (global). 6 mismatches в activity_log_y2026_m05 -
следствие неправильного rebuild'а, не оригинальной порчи.

Decision (User: Дмитрий): per-tenant canonical через RLS scope. Trigger и
verify уже согласованы; AuditRebuildChain - bug, переделать в Stage 5
follow-up (отдельный plan). После фикса re-run на activity_log_y2026_m05 -
6 mismatches исчезнут.

Альтернатива global semantics + переписать trigger SECURITY DEFINER + миграция
БД отвергнута: ослабляет 152-ФЗ tamper-detection + рискованная миграция.

Cross-links: ADR-002 RLS multi-tenancy, incidents/2026-05-29-disk-full-pg-recovery.md,
F1 advisory-lock migration 2026_05_30_000001.

Enforcement-block declarative (require_pattern AuditChainConfig::TABLES) -
активируется после имплементации Stage 5 follow-up.

cspell-words.txt: +партиционированы
This commit is contained in:
Дмитрий
2026-05-29 15:32:46 +03:00
parent a6bde2125a
commit 0098db6628
2 changed files with 228 additions and 0 deletions
+1
View File
@@ -463,6 +463,7 @@ slugs
партиционированной
партиционированием
партиционирована
партиционированы
Партнёрка
виртуализация
виртуализацией
@@ -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 <partition> 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` потому что **архитектурное решение** принято;
имплементация — отдельная задача.