Files
portal/docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
T
Дмитрий 4121bc121a docs(adr): ADR-018 enforcement активирован (Tasks 2+4 завершены)
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>
2026-05-29 18:14:40 +03:00

13 KiB
Raw Blame History

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. Команда AuditRebuildChainbug, должна быть переписана для воспроизведения 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 таблиц зарегистрированы.
  • ADR-002 (Multi-tenancy через PostgreSQL RLS) — основа: RLS scope, через который trigger автоматически получает per-tenant chain semantics для tenant-таблиц.
  • Incident 2026-05-29 disk-full PG recoverydocs/incidents/2026-05-29-disk-full-pg-recovery.md — контекст обнаружения расхождения.
  • F1 advisory-lock migrationapp/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

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 этого плана.