fix(audit): hash-chain canonical → global-within-partition (ADR-021, supersedes ADR-018)
Ночной audit:verify-chains падал на pd_processing_log (ложный «high»-инцидент + письма kdv1@bk.ru). Проверка на живом кластере (read-only, хеши server-side) показала: ВСЕ 4 «per-tenant» audit-таблицы (activity_log, tenant_operations_log, balance_transactions, pd_processing_log) записаны ГЛОБАЛЬНОЙ цепочкой (0 global-mismatch), per-tenant верификация ложно ругается на стыках клиентов. Предпосылка ADR-018 (триггер даёт per-tenant через RLS) эмпирически неверна — изоляция никогда не была реализована. Данные целостны. Решение владельца (с учётом что задет и денежный balance_transactions): канон — global. AuditChainConfig: partition='' для 4 таблиц (verify/rebuild читают конфиг → авто-global). Данные/схему/триггер/деньги НЕ трогаем (0 mismatch под global, rebuild не нужен). TDD: новый тест «cross-tenant rows chained by trigger (global) verify intact»; обновлены 3 теста старого per-tenant поведения (config-mirror, per-tenant regression → supersede, rebuild per-tenant → global). Тесты: 30 audit + 23 Pd/Scheduler зелёные, pint + PHPStan(0) чисто. Решение задокументировано в docs/adr/ADR-021; ADR-018 → Superseded. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,11 @@ use InvalidArgumentException;
|
||||
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
|
||||
* (App\Console\Commands\AuditRebuildChain).
|
||||
*
|
||||
* ADR-018: per-tenant via RLS scope for tenant tables,
|
||||
* global for BYPASSRLS tables.
|
||||
* ADR-021 (supersedes ADR-018): global-within-partition для ВСЕХ таблиц.
|
||||
* Эмпирика на проде 2026-07-09 (все 4 бывшие «per-tenant» таблицы, включая
|
||||
* balance_transactions): триггер `audit_chain_hash()` де-факто пишет глобальную
|
||||
* цепочку (RLS не скоупит его SELECT) — per-tenant изоляция ADR-018 никогда не
|
||||
* была реализована. Verify/rebuild приведены к реальности (partition => '').
|
||||
*
|
||||
* columns: list in ordinal_position order from db/schema.sql.
|
||||
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
|
||||
@@ -43,7 +46,7 @@ final class AuditChainConfig
|
||||
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
|
||||
],
|
||||
'tenant_operations_log' => [
|
||||
'columns' => [
|
||||
@@ -51,7 +54,7 @@ final class AuditChainConfig
|
||||
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
|
||||
],
|
||||
'balance_transactions' => [
|
||||
'columns' => [
|
||||
@@ -60,7 +63,7 @@ final class AuditChainConfig
|
||||
'related_type', 'related_id', 'user_id', 'admin_user_id',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
|
||||
],
|
||||
'pd_processing_log' => [
|
||||
'columns' => [
|
||||
@@ -68,7 +71,7 @@ final class AuditChainConfig
|
||||
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
'partition' => '', // ADR-021: global-within-partition (per-tenant never реализован)
|
||||
],
|
||||
'saas_admin_audit_log' => [
|
||||
'columns' => [
|
||||
|
||||
@@ -232,7 +232,7 @@ it('audit:rebuild-chain rejects unknown partition names', function (): void {
|
||||
// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
|
||||
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';
|
||||
|
||||
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
|
||||
it('audit:rebuild-chain produces global chain matching trigger semantics в activity_log (ADR-021)', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
@@ -253,15 +253,10 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в
|
||||
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
|
||||
|
||||
// NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
|
||||
// `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
|
||||
// global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
|
||||
// (валидация — live `audit:verify-chains` на проде, не в этом тесте).
|
||||
//
|
||||
// Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
|
||||
// partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
|
||||
// verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
|
||||
// rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.
|
||||
// ADR-021 (supersedes ADR-018): канон — global-within-partition для всех таблиц.
|
||||
// На проде (проверено 2026-07-09) и в test env (postgres superuser) trigger пишет
|
||||
// GLOBAL chain. rebuild читает partition='' из AuditChainConfig → тоже global →
|
||||
// verify под global scope match → GREEN.
|
||||
|
||||
$exit = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
@@ -270,8 +265,8 @@ it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в
|
||||
]);
|
||||
expect($exit)->toBe(0);
|
||||
|
||||
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
|
||||
$postMismatches = checkPartitionIntegrity($partition, '', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Rebuild должен produce global chain (ADR-021, supersedes ADR-018 per-tenant)');
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
|
||||
|
||||
@@ -151,117 +151,18 @@ test('multiple rows in auth_log all pass intact', function () {
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// MULTI-TENANT REGRESSION: per-scope validator must not false-positive on
|
||||
// data with multiple tenants.
|
||||
// MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021.
|
||||
//
|
||||
// Problem reproduced on prod: the old global validator computed LAG OVER
|
||||
// (ORDER BY id) across ALL tenants. When tenant B's first row followed
|
||||
// tenant A's last row, its stored_hash was SHA256(A_last.log_hash || B_row),
|
||||
// but the global recompute gave the same thing — so it was fine globally.
|
||||
// However, on PROD (crm_app_user, NOT BYPASSRLS) the trigger's SELECT only
|
||||
// sees the current tenant's rows, so B_row[0] is chained off '' (no prev),
|
||||
// not off A_last. The global validator then reported a false breach at every
|
||||
// tenant boundary.
|
||||
//
|
||||
// This test replicates PROD conditions by bypassing the trigger and manually
|
||||
// inserting rows with CORRECTLY per-tenant-chained hashes (what the trigger
|
||||
// would produce under RLS on prod). The per-scope validator must then report
|
||||
// INTACT, confirming the partition logic is correct.
|
||||
//
|
||||
// Why bypass the trigger for this test:
|
||||
// On dev (postgres = superuser), the trigger's SELECT has no RLS filter and
|
||||
// sees ALL rows globally. Inserting via the trigger would produce a GLOBAL
|
||||
// chain regardless of the app.current_tenant_id GUC. To test the validator's
|
||||
// per-partition recompute, we need rows whose stored hashes were computed
|
||||
// with per-tenant prev_hash — exactly what the trigger produces on prod.
|
||||
// We reproduce that by disabling triggers and computing the hashes ourselves
|
||||
// using the same digest(COALESCE(prev,'')||row::text,'sha256') formula with
|
||||
// per-partition LAG, matching the validator's own recompute SQL.
|
||||
// The former test here ('per-tenant chained tenant_operations_log validates
|
||||
// intact') manually seeded PER-TENANT-chained hashes and asserted the per-tenant
|
||||
// validator accepted them. Its premise — that the trigger produces a per-tenant
|
||||
// chain on prod — was empirically disproven 2026-07-09: the trigger produces a
|
||||
// GLOBAL chain for all tables (incl. balance_transactions). ADR-021 makes global
|
||||
// the canonical scope. The real, prod-accurate scenario (mixed-tenant rows
|
||||
// chained BY THE TRIGGER = global) is covered by
|
||||
// 'cross-tenant rows chained by the trigger (global) verify intact — ADR-021'.
|
||||
// ===========================================================================
|
||||
|
||||
test('per-tenant chained tenant_operations_log validates intact (prod-behaviour regression)', function () {
|
||||
$tid1 = ensureTenant(1);
|
||||
$tid2 = ensureTenant(2);
|
||||
|
||||
// Disable triggers so we can insert rows with manually computed hashes
|
||||
// that simulate per-tenant chaining (what the trigger produces on prod).
|
||||
DB::statement('ALTER TABLE tenant_operations_log DISABLE TRIGGER USER');
|
||||
|
||||
try {
|
||||
// Insert 3 rows for tenant 1 and 2 rows for tenant 2, interleaved by id.
|
||||
// We insert WITHOUT log_hash first, then update with per-tenant-correct hashes.
|
||||
// (INSERT with log_hash=NULL, then fill via SQL digest to avoid PHP bytea handling.)
|
||||
|
||||
$rows = [
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 10, 'event' => 'project.created', 'created_at' => now()],
|
||||
['tenant_id' => $tid2, 'entity_type' => 'api_key', 'entity_id' => 20, 'event' => 'api_key.regenerated', 'created_at' => now()],
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 11, 'event' => 'project.updated', 'created_at' => now()],
|
||||
['tenant_id' => $tid2, 'entity_type' => 'project', 'entity_id' => 21, 'event' => 'project.created', 'created_at' => now()],
|
||||
['tenant_id' => $tid1, 'entity_type' => 'project', 'entity_id' => 12, 'event' => 'project.deleted', 'created_at' => now()],
|
||||
];
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
// log_hash left NULL; we will fill it below via SQL
|
||||
$ids[] = (int) DB::table('tenant_operations_log')->insertGetId($row);
|
||||
}
|
||||
|
||||
// Now fill log_hash for each inserted row using per-tenant-partitioned prev_hash,
|
||||
// mirroring exactly what the trigger produces under RLS on prod:
|
||||
// log_hash = digest(COALESCE(prev_tenant_hash, ''::bytea) || ROW(...)::text::bytea, 'sha256')
|
||||
// where prev_tenant_hash = last log_hash of the SAME tenant ordered by id.
|
||||
//
|
||||
// We do this in id-order one row at a time so each hash feeds the next.
|
||||
$idList = implode(',', $ids);
|
||||
|
||||
DB::statement(<<<SQL
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
id,
|
||||
tenant_id,
|
||||
ROW(id, tenant_id, user_id, entity_type, entity_id, event,
|
||||
payload_before, payload_after, ip_address, user_agent,
|
||||
NULL::bytea, created_at) AS row_val,
|
||||
ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY id) AS rn
|
||||
FROM tenant_operations_log
|
||||
WHERE id IN ({$idList})
|
||||
)
|
||||
UPDATE tenant_operations_log tgt
|
||||
SET log_hash = (
|
||||
WITH RECURSIVE chain(id, tenant_id, rn, hash) AS (
|
||||
-- Base: first row per tenant (rn=1), prev_hash = ''
|
||||
SELECT r.id, r.tenant_id, r.rn,
|
||||
digest(''::bytea || r.row_val::text::bytea, 'sha256')
|
||||
FROM ranked r
|
||||
WHERE r.rn = 1
|
||||
UNION ALL
|
||||
-- Recursive: each subsequent row chains off previous
|
||||
SELECT r.id, r.tenant_id, r.rn,
|
||||
digest(c.hash || r.row_val::text::bytea, 'sha256')
|
||||
FROM chain c
|
||||
JOIN ranked r ON r.tenant_id = c.tenant_id AND r.rn = c.rn + 1
|
||||
)
|
||||
SELECT hash FROM chain WHERE id = tgt.id
|
||||
)
|
||||
WHERE id IN ({$idList})
|
||||
SQL);
|
||||
} finally {
|
||||
DB::statement('ALTER TABLE tenant_operations_log ENABLE TRIGGER USER');
|
||||
}
|
||||
|
||||
// Verify all 5 rows got their hashes set (sanity check on the SQL above)
|
||||
$nullCount = DB::table('tenant_operations_log')
|
||||
->whereIn('id', $ids)
|
||||
->whereNull('log_hash')
|
||||
->count();
|
||||
expect($nullCount)->toBe(0, 'All inserted rows must have log_hash set by the chain SQL');
|
||||
|
||||
// The per-scope validator must report INTACT — no false breach at tenant boundary
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Tampering detection
|
||||
// ===========================================================================
|
||||
@@ -500,3 +401,36 @@ test('exit code is FAILURE on breach even when no active admin exists for incide
|
||||
// But email alert must still be sent
|
||||
Mail::assertSent(AuditChainBreachMail::class);
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// ADR-021: audit hash-chain canonical is GLOBAL-within-partition for all tables.
|
||||
// The trigger produces a global chain (verified on prod 2026-07-09 for all 4
|
||||
// formerly-"per-tenant" tables incl. balance_transactions; also true on
|
||||
// dev/superuser). Mixed-tenant rows inserted VIA THE TRIGGER must therefore
|
||||
// verify intact — the old per-tenant validator (ADR-018) false-positived at
|
||||
// every tenant boundary. Supersedes the per-tenant regression test above.
|
||||
// ===========================================================================
|
||||
|
||||
test('cross-tenant rows chained by the trigger (global) verify intact — ADR-021', function () {
|
||||
$t1 = ensureTenant(1);
|
||||
$t2 = ensureTenant(2);
|
||||
|
||||
// Insert via the normal path (trigger fills log_hash). On dev/superuser the
|
||||
// trigger chains GLOBALLY — exactly what prod produces. Interleave tenants so
|
||||
// the chain crosses tenant boundaries (id2:t2 after id1:t1, etc.).
|
||||
insertTenantOpsRow($t1, 'project.created');
|
||||
insertTenantOpsRow($t2, 'api_key.regenerated');
|
||||
insertTenantOpsRow($t1, 'project.updated');
|
||||
insertTenantOpsRow($t2, 'project.created');
|
||||
insertTenantOpsRow($t1, 'project.deleted');
|
||||
|
||||
// Under global scope (ADR-021) this is a valid intact chain → no breach.
|
||||
$this->artisan('audit:verify-chains')->assertSuccessful();
|
||||
|
||||
$count = DB::connection('pgsql_supplier')
|
||||
->table('incidents_log')
|
||||
->where('summary', 'like', '%chain%tenant_operations_log%')
|
||||
->count();
|
||||
expect($count)->toBe(0);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
@@ -15,14 +15,13 @@ it('exposes all 6 audit tables', function (): void {
|
||||
]);
|
||||
});
|
||||
|
||||
it('activity_log uses PARTITION BY tenant_id', function (): void {
|
||||
expect(AuditChainConfig::TABLES['activity_log']['partition'])
|
||||
->toEqual('PARTITION BY tenant_id');
|
||||
});
|
||||
|
||||
it('auth_log and saas_admin_audit_log use global chain (empty partition)', function (): void {
|
||||
expect(AuditChainConfig::TABLES['auth_log']['partition'])->toEqual('');
|
||||
expect(AuditChainConfig::TABLES['saas_admin_audit_log']['partition'])->toEqual('');
|
||||
it('all 6 audit tables use global-within-partition chain (ADR-021, supersedes ADR-018)', function (): void {
|
||||
// ADR-021: триггер де-факто пишет глобальную цепочку для ВСЕХ таблиц (проверено на
|
||||
// проде 2026-07-09, включая balance_transactions). per-tenant scope ADR-018 никогда
|
||||
// не был реализован → все partition-clause пустые (global-within-partition).
|
||||
foreach (AuditChainConfig::TABLES as $table => $cfg) {
|
||||
expect($cfg['partition'])->toEqual('', "table {$table} must use global chain");
|
||||
}
|
||||
});
|
||||
|
||||
it('rowExpression builds ROW(...) with NULL::bytea at __log_hash__ position', function (): void {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
# ADR-018: Audit hash-chain semantics — per-tenant (через RLS scope) canonical
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Status:** Superseded by [ADR-021](ADR-021-audit-chain-global-scope.md) (2026-07-09)
|
||||
- **Date:** 2026-05-29
|
||||
- **Deciders:** User: Дмитрий (business policy 152-ФЗ)
|
||||
|
||||
> ⚠️ **Superseded 2026-07-09 (ADR-021):** предпосылка этого ADR — что триггер
|
||||
> `audit_chain_hash()` под RLS даёт per-tenant цепочку — **эмпирически опровергнута на проде**:
|
||||
> все 4 audit-таблицы (включая `balance_transactions`) записаны глобальной цепочкой, per-tenant
|
||||
> изоляция никогда не была реализована. Канон изменён на global-within-partition. Текст ниже
|
||||
> сохранён без изменений (immutability). Детали и данные — в ADR-021.
|
||||
|
||||
## Context
|
||||
|
||||
Портал ведёт 6 append-only audit-таблиц с криптографической SHA-256 hash-chain
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# ADR-021: Audit hash-chain semantics — global-within-partition canonical (supersedes ADR-018)
|
||||
|
||||
- **Status:** Accepted. Date: 2026-07-09.
|
||||
- **Decision Maker:** User: Дмитрий (152-ФЗ business policy), после эмпирической проверки на проде.
|
||||
- **Supersedes:** ADR-018 (Audit hash-chain semantics — per-tenant canonical).
|
||||
|
||||
## Context
|
||||
|
||||
[ADR-018](ADR-018-audit-chain-per-tenant-semantics.md) (2026-05-29) зафиксировал **per-tenant**
|
||||
как канон hash-chain для четырёх tenant-таблиц (`activity_log`, `tenant_operations_log`,
|
||||
`balance_transactions`, `pd_processing_log`). Решение опиралось на **предположение**, что
|
||||
триггер `audit_chain_hash()` под RLS вставляющей сессии видит только строки своего tenant'а и
|
||||
потому автоматически строит per-tenant цепочку (ADR-018 §36).
|
||||
|
||||
09.07.2026 ночной `audit:verify-chains` начал падать (exit 1) на
|
||||
`pd_processing_log_y2026_m07`, первый сломанный `id=29`, писать high-инцидент в `incidents_log`
|
||||
и слать письма на `kdv1@bk.ru`. Разрыв приходился ровно на границу клиентов (id=28 tenant 2 →
|
||||
id=29 tenant 1, свежая запись `lead_create_supplier`).
|
||||
|
||||
Эмпирическая проверка на живом кластере `c9q2cvtjpq3hgq6l0r96` (read-only; хеши считались
|
||||
на стороне PostgreSQL через `digest(...)`, персональные данные прод не покидали — сравнивались
|
||||
только булевы результаты) по **всем** per-tenant audit-таблицам:
|
||||
|
||||
| Таблица | Строк | GLOBAL mismatch | PER-TENANT mismatch |
|
||||
|---|---|---|---|
|
||||
| `activity_log` | 112 | **0** | 1 |
|
||||
| `tenant_operations_log` | 45 | **0** | 2 |
|
||||
| `balance_transactions` (деньги) | 168 | **0** | 2 |
|
||||
| `pd_processing_log` | 126 | **0** | 1 |
|
||||
|
||||
Вывод, подтверждённый хешами: на проде **все** audit-таблицы записаны **глобальной** цепочкой
|
||||
(0 несовпадений под global-обходом), а per-tenant верификация ложно срабатывает на каждом стыке
|
||||
клиентов. То есть **предположение ADR-018 эмпирически ложно — per-tenant изоляция цепочки
|
||||
никогда не была реализована** (триггер де-факто читает последнюю строку партиции глобально,
|
||||
несмотря на RLS и `SET LOCAL app.current_tenant_id`). Данные при этом **целостны** (globally
|
||||
consistent, тамперинга нет).
|
||||
|
||||
Записи `pd_processing_log`/`activity_log`/`balance_transactions` пишутся в ОДНОЙ транзакции
|
||||
`RouteSupplierLeadJob` ([app/app/Jobs/RouteSupplierLeadJob.php:475-491](../../app/app/Jobs/RouteSupplierLeadJob.php#L475)),
|
||||
поэтому попытка «сделать per-tenant по-настоящему» неизбежно затрагивает и денежный
|
||||
`balance_transactions`.
|
||||
|
||||
## Decision
|
||||
|
||||
**Канон audit hash-chain — global-within-partition для всех 6 таблиц.** В
|
||||
`AuditChainConfig::TABLES` для `activity_log` / `tenant_operations_log` / `balance_transactions`
|
||||
/ `pd_processing_log` установить `partition => ''` (как уже у `auth_log` / `saas_admin_audit_log`).
|
||||
`VerifyAuditChains` и `AuditRebuildChain` читают этот конфиг → автоматически переходят на global.
|
||||
|
||||
Данные **не пересобираются** — они уже globally-consistent (0 mismatch под global). Триггер
|
||||
`audit_chain_hash()` **не меняется** (он уже де-факто глобальный). Схема БД и деньги не
|
||||
трогаются. Изменение — только PHP-конфиг + тесты.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative A: Enforce per-tenant по-настоящему (отклонено)
|
||||
|
||||
Переделать триггер `audit_chain_hash()` на реальный per-tenant scope (SECURITY DEFINER + явная
|
||||
фильтрация по tenant_id, или гарантированный RLS-контекст у всех писателей) и пересчитать
|
||||
(`audit:rebuild-chain`) hash-цепочку во **всех** четырёх журналах, включая денежный
|
||||
`balance_transactions`.
|
||||
|
||||
**User Feedback (Дмитрий, 09.07):** отклонено. Большая рискованная операция на проде по денежным
|
||||
записям ради изоляции, которая **никогда не работала**. Ценность не оправдывает риск.
|
||||
|
||||
**Cons:** высокий риск на проде; трогает money audit-chain; повышает attack surface триггера
|
||||
(SECURITY DEFINER); долгий проект.
|
||||
|
||||
### Alternative B: Global canonical (выбрано)
|
||||
|
||||
Как описано в Decision.
|
||||
|
||||
**Pros:** verify сходится (0 mismatch); ложные ночные инциденты и письма прекращаются; БД и
|
||||
деньги не трогаем; writer / verify / rebuild становятся консистентны с реальностью;
|
||||
tamper-detection сохраняется (любая подмена рвёт общую цепь — строже, чем per-tenant).
|
||||
|
||||
**Cons:** формально отменяет per-tenant решение ADR-018.
|
||||
|
||||
### Alternative C: Do nothing (отклонено)
|
||||
|
||||
Оставить как есть.
|
||||
|
||||
**Cons:** каждую ночь ложный high-инцидент + письма на `kdv1@bk.ru`; накопление вечно-открытых
|
||||
инцидентов; расхождение writer/verify не закрыто.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Benefits**
|
||||
|
||||
- `audit:verify-chains` проходит (0 mismatch по всем 6 таблицам) — ложные алёрты прекращаются.
|
||||
- Никаких изменений данных, схемы, триггера, денег — минимальный риск (одна PHP-константа + тесты).
|
||||
- Единая семантика во всех трёх местах (writer / verify / rebuild) — соответствует реальности.
|
||||
- Tamper-detection не ослаблен: общая цепь рвётся при любой подмене где угодно в партиции.
|
||||
|
||||
**Trade-offs**
|
||||
|
||||
- Теряется аспирационная per-tenant изоляция журналов из ADR-018 — но она de-facto никогда не
|
||||
была включена, поэтому реальной защиты не убывает.
|
||||
|
||||
**Risks and mitigations**
|
||||
|
||||
- *Risk:* 152-ФЗ может юридически требовать раздельного/изолированного хранения журналов на
|
||||
клиента. *Mitigation:* глобальная hash-цепь удовлетворяет требование **фиксации и
|
||||
неизменяемости** операций обработки ПДн (ст.18 ч.2). Строгая per-tenant **изоляция** журналов —
|
||||
отдельный юридический вопрос; при необходимости — отдельный проект с юристом (Alternative A).
|
||||
- *Risk:* будущий писатель начнёт (правильно) писать per-tenant → снова разъедется. *Mitigation:*
|
||||
зафиксировано в этом ADR + enforcement на общий `AuditChainConfig`.
|
||||
|
||||
**Open item (не блокирует решение)**
|
||||
|
||||
Точная причина, почему триггер видит строки глобально несмотря на RLS-политику
|
||||
`tenant_isolation` и `SET LOCAL app.current_tenant_id` в `RouteSupplierLeadJob` (роль очереди /
|
||||
RLS / потенциальный BYPASSRLS на проде) — не докопана. На решение не влияет (принимаем
|
||||
глобальную реальность как канон), но стоит зафиксировать для будущего RLS-ревью — вдруг
|
||||
изоляция ПДн задумывалась и на другом уровне.
|
||||
|
||||
## Related Decisions
|
||||
|
||||
- **ADR-018 (Audit hash-chain — per-tenant)** — **superseded этим ADR.** Его предпосылка
|
||||
(триггер даёт per-tenant через RLS) эмпирически опровергнута на проде 09.07.2026.
|
||||
- **ADR-002 (Multi-tenancy через PostgreSQL RLS)** — RLS остаётся источником изоляции **данных**;
|
||||
этот ADR декуплирует scope audit-цепочки от RLS (цепочка — глобальная в пределах партиции).
|
||||
|
||||
## References
|
||||
|
||||
- Prod-измерения 2026-07-09 (read-only, live cluster) — таблица в Context.
|
||||
- [app/app/Services/Audit/AuditChainConfig.php](../../app/app/Services/Audit/AuditChainConfig.php) — `partition` per table (место правки).
|
||||
- [app/app/Console/Commands/VerifyAuditChains.php](../../app/app/Console/Commands/VerifyAuditChains.php) — читает конфиг.
|
||||
- [app/app/Console/Commands/AuditRebuildChain.php](../../app/app/Console/Commands/AuditRebuildChain.php) — читает конфиг.
|
||||
- [db/schema.sql](../../db/schema.sql) — `audit_chain_hash()` (не меняется).
|
||||
- [app/app/Jobs/RouteSupplierLeadJob.php:475-491](../../app/app/Jobs/RouteSupplierLeadJob.php#L475) — совместная транзакция pd/activity/balance.
|
||||
- Memory: `project-audit-chain-pdlog-cross-tenant-divergence-2026-07-09`.
|
||||
- 152-ФЗ ст.18 ч.2 — фиксация операций обработки ПДн.
|
||||
|
||||
## Enforcement
|
||||
|
||||
```json
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"id": "verify-must-use-shared-config",
|
||||
"description": "VerifyAuditChains должна читать TABLES из AuditChainConfig — не дублировать semantics локально",
|
||||
"applies_to": ["app/app/Console/Commands/VerifyAuditChains.php"],
|
||||
"require_pattern": "AuditChainConfig::TABLES|AuditChainConfig::rowExpression"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"llm_judge": false
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation (отдельный шаг, TBD)
|
||||
|
||||
Само изменение кода (не входит в этот ADR-record):
|
||||
|
||||
1. `AuditChainConfig::TABLES` — `partition => ''` для `activity_log`, `tenant_operations_log`,
|
||||
`balance_transactions`, `pd_processing_log`.
|
||||
2. TDD-тест: `VerifyAuditChains` проходит для смешанных cross-tenant партиций под global scope.
|
||||
3. Прогнать `audit:verify-chains` (dev → прод read-only) — ожидаем 0 mismatch без rebuild.
|
||||
4. Выкат PHP-оверлеем (без миграций, без rebuild данных). Схему БД и `db/CHANGELOG_schema.md` не трогаем.
|
||||
Reference in New Issue
Block a user