Task 5 плана 2026-05-29-audit-rebuild-per-tenant-fix.md. Активированы 2 декларативных правила в ADR-018: - rebuild-must-use-shared-config: AuditRebuildChain.php должен читать partition_clause из AuditChainConfig (require_pattern matches существующему коду после Task 4 fix). - verify-must-use-shared-config: VerifyAuditChains.php должен читать TABLES из AuditChainConfig (require_pattern matches коду после Task 2 refactor). llm_judge=false (declarative only, zero cost). adr-judge на staged diff: 0 violations / 0 advisories. Ref: docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
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).
Все 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 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 | LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id) — корректно воспроизводит per-tenant scope триггера |
| Rebuild | app/app/Console/Commands/AuditRebuildChain.php:135-180 | 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 при
пересчёте.
Конкретно:
-
Writer (trigger
audit_chain_hash()) — без изменений. Он уже даёт правильную семантику автоматически через RLS scope. -
Verify (
VerifyAuditChains::TABLE_CONFIG) — без изменений. Текущий конфиг корректно отражает реальность: per-tenant для tenant-таблиц, global для admin/auth-таблиц. -
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 как сейчас.
- для
-
Очистка 6 mismatches в
activity_log_y2026_m05— после фикса rebuild'а: re-runaudit: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::TABLESconstant- assertion в
VerifyAuditChains::handle()что все 6 таблиц зарегистрированы.
- assertion в
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 —
audit_chain_hash()trigger function - db/schema.sql:3148-3188 — 6 пар триггеров (BEFORE INSERT + UPDATE/DELETE block)
- app/app/Console/Commands/VerifyAuditChains.php — verify command (per-tenant + global)
- 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 — 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
{
"rules": [
{
"id": "rebuild-must-use-shared-config",
"description": "AuditRebuildChain должна читать partition_clause из AuditChainConfig — не определять semantics локально",
"applies_to": ["app/app/Console/Commands/AuditRebuildChain.php"],
"require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression"
},
{
"id": "verify-must-use-shared-config",
"description": "VerifyAuditChains должна читать TABLES из AuditChainConfig — не дублировать private const",
"applies_to": ["app/app/Console/Commands/VerifyAuditChains.php"],
"require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression"
}
],
"llm_judge": false
}
Декларативные правила активированы после Tasks 2 и 4 этого плана.