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:
@@ -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` потому что **архитектурное решение** принято;
|
||||
имплементация — отдельная задача.
|
||||
Reference in New Issue
Block a user