Files
portal/app/tests/Feature/Console/VerifyAuditChainsTest.php
T
Дмитрий 1495cd643d
Accessibility (Pa11y live) / a11y (push) Waiting to run
SAST — Semgrep / Semgrep SAST scan (push) Waiting to run
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>
2026-07-09 08:54:10 +03:00

437 lines
17 KiB
PHP

<?php
declare(strict_types=1);
use App\Mail\AuditChainBreachMail;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
// ---------------------------------------------------------------------------
// Helper: ensure at least one active saas_admin_user row exists (FK for incidents_log)
// ---------------------------------------------------------------------------
function ensureAuditAdmin(): int
{
$id = DB::table('saas_admin_users')
->where('is_active', true)
->whereNull('deleted_at')
->value('id');
if ($id !== null) {
return (int) $id;
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => 'audit-cron@liderra.ru',
'full_name' => 'Audit Cron',
'password_hash' => '$2y$12$placeholder',
'role' => 'dev_oncall',
'is_active' => true,
'created_at' => now(),
]);
}
// ---------------------------------------------------------------------------
// Helper: insert N rows into auth_log via the normal path (trigger fills log_hash).
// Uses the 3rd constraint variant: actor_type='tenant_user', user_id=NULL,
// saas_admin_user_id=NULL, email IS NOT NULL (login attempt with unknown email).
// ---------------------------------------------------------------------------
function insertAuthLogRows(int $n, ?int $tenantId = null): void
{
for ($i = 0; $i < $n; $i++) {
DB::table('auth_log')->insert([
'actor_type' => 'tenant_user',
'tenant_id' => $tenantId,
'email' => "test{$i}@example.com",
'event' => 'login_failed',
'ip_address' => '127.0.0.1',
'created_at' => now(),
]);
}
}
// ---------------------------------------------------------------------------
// Helper: ensure a tenant row exists, return its id.
// ---------------------------------------------------------------------------
function ensureTenant(int $seed): int
{
$existing = DB::table('tenants')->where('subdomain', "test-chain-{$seed}")->value('id');
if ($existing !== null) {
return (int) $existing;
}
return (int) DB::table('tenants')->insertGetId([
'organization_name' => "Test Chain {$seed}",
'subdomain' => "test-chain-{$seed}",
'contact_email' => "chain{$seed}@example.com",
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
}
// ---------------------------------------------------------------------------
// Helper: insert one row into tenant_operations_log under a specific tenant.
// The trigger fills log_hash based on what it can SELECT (see notes below).
// ---------------------------------------------------------------------------
function insertTenantOpsRow(int $tenantId, string $event = 'project.created'): int
{
return (int) DB::table('tenant_operations_log')->insertGetId([
'tenant_id' => $tenantId,
'entity_type' => 'project',
'entity_id' => 1,
'event' => $event,
'created_at' => now(),
]);
}
// ---------------------------------------------------------------------------
// Helper: build per-tenant chained hashes in SQL (mirrors the validator logic).
//
// This computes what the per-scope validator expects: for each row in
// tenant_operations_log, prev_hash = LAG(log_hash) OVER (PARTITION BY tenant_id
// ORDER BY id). We use this to manually seed correctly-chained rows when we
// need to bypass the trigger (which on dev/superuser chains globally).
//
// Returns array of ['id' => int, 'hash' => resource] sorted by id.
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
beforeEach(function () {
ensureAuditAdmin();
Mail::fake();
});
// ===========================================================================
// TDD ANCHOR: clean chain must verify intact (serialization correctness gate)
// ===========================================================================
test('clean auth_log chain verifies intact', function () {
insertAuthLogRows(3);
$exitCode = Artisan::call('audit:verify-chains');
$out = Artisan::output();
if ($exitCode !== 0) {
dump('OUTPUT:', $out);
}
expect($exitCode)->toBe(0);
// No incident should be created for an intact chain
$count = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('summary', 'like', '%chain%auth_log%')
->count();
expect($count)->toBe(0);
// No email should be sent
Mail::assertNothingSent();
});
test('empty tables are skipped gracefully (no false positive)', function () {
// auth_log might have rows from other tests but other tables are empty on dev.
// The command must not raise an error on empty tables.
$this->artisan('audit:verify-chains')->assertSuccessful();
Mail::assertNothingSent();
});
test('multiple rows in auth_log all pass intact', function () {
insertAuthLogRows(10);
$this->artisan('audit:verify-chains')->assertSuccessful();
Mail::assertNothingSent();
});
// ===========================================================================
// MULTI-TENANT REGRESSION — SUPERSEDED by ADR-021.
//
// 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'.
// ===========================================================================
// ===========================================================================
// Tampering detection
// ===========================================================================
test('tampered auth_log row raises incident and sends email', function () {
insertAuthLogRows(3);
// Sanity: intact before tampering
$this->artisan('audit:verify-chains')->assertSuccessful();
Mail::assertNothingSent();
// Tamper: disable triggers, mutate the first row's ip_address, re-enable.
// DISABLE TRIGGER USER disables all user-defined triggers (audit_block_mutation
// is BEFORE UPDATE, so without disabling it the UPDATE would be blocked).
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
DB::table('auth_log')
->orderBy('id')
->limit(1)
->update(['ip_address' => '6.6.6.6']);
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
// Now the command must detect the breach
$this->artisan('audit:verify-chains')->assertFailed();
$incident = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('type', 'other')
->where('severity', 'high')
->where('summary', 'like', '%chain%auth_log%')
->first();
expect($incident)->not->toBeNull();
expect($incident->summary)->toContain('auth_log');
Mail::assertSent(AuditChainBreachMail::class, function ($mail) {
return $mail->tableName === 'auth_log';
});
});
test('incident dedup: same table breach does not create duplicate within 24h', function () {
insertAuthLogRows(3);
// First tamper
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
DB::table('auth_log')
->orderBy('id')
->limit(1)
->update(['ip_address' => '5.5.5.5']);
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
$this->artisan('audit:verify-chains')->assertFailed();
$countAfterFirst = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('summary', 'like', '%chain%auth_log%')
->count();
expect($countAfterFirst)->toBe(1);
// Second run (same ongoing breach) must NOT create a second incident (dedup)
$this->artisan('audit:verify-chains')->assertFailed();
$countAfterSecond = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('summary', 'like', '%chain%auth_log%')
->count();
expect($countAfterSecond)->toBe(1);
});
// ===========================================================================
// PARTITION-AWARE: breach in one partition must record incident with
// partition_name; other partitions must remain intact (no false positive).
// ===========================================================================
test('breach in one partition is detected; other partitions reported intact', function () {
// Insert 3 rows into auth_log — trigger assigns log_hash correctly.
// After the migration, auth_log is partitioned by month; all test rows
// go into the current month's partition (e.g. auth_log_y2026_m05).
insertAuthLogRows(3);
// Sanity: chain intact before tampering.
$this->artisan('audit:verify-chains')->assertSuccessful();
Mail::assertNothingSent();
// Determine which partition the inserted rows landed in, so we can assert
// the incident summary references that partition, not just "auth_log".
//
// Rows are inserted with created_at = now(), so they land in the partition
// whose range covers the current month. We derive the expected name the
// same way buildPartitionDDL() does: {table}_y{YYYY}_m{MM}.
//
// Fallback: if auth_log has no children (migration not applied), we expect
// the incident to reference 'auth_log' itself (command's fallback path).
$partitionCount = DB::selectOne(
"SELECT COUNT(*) AS cnt
FROM pg_inherits i
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'auth_log'",
)->cnt ?? 0;
$expectedPartition = $partitionCount > 0
? sprintf('auth_log_y%s_m%s', now()->format('Y'), now()->format('m'))
: 'auth_log';
// Tamper the first row in auth_log (which lands in $expectedPartition).
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
DB::table('auth_log')
->orderBy('id')
->limit(1)
->update(['ip_address' => '7.7.7.7']);
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
// Command must fail and reference the partition in the incident summary.
$this->artisan('audit:verify-chains')->assertFailed();
$incident = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('type', 'other')
->where('severity', 'high')
->where('summary', 'like', '%chain%auth_log%')
->orderByDesc('id')
->first();
expect($incident)->not->toBeNull('An incident must be recorded on chain breach');
// The incident summary must mention the specific partition (or the table if not yet partitioned).
expect($incident->summary)->toContain($expectedPartition);
// Email must be sent referencing the correct partition.
Mail::assertSent(AuditChainBreachMail::class, function ($mail) use ($expectedPartition) {
// partitionName is the 4th constructor arg (nullable); falls back to tableName.
$actualPartition = $mail->partitionName ?? $mail->tableName;
return $actualPartition === $expectedPartition && $mail->tableName === 'auth_log';
});
});
// ===========================================================================
// EXIT CODE: breach must always return FAILURE regardless of incident write
// ===========================================================================
// ===========================================================================
// AUTO-RESOLVE: when a table's chain is fully intact, a previously-open
// chain incident for that table must be auto-resolved (stale-incident cleanup).
// Driven by FN-AUDIT (acceptance run 22.06): transient breaches on test-tenant
// rows left incidents open after teardown deleted the offending rows.
// ===========================================================================
test('intact table auto-resolves a previously-open chain incident', function () {
$adminId = ensureAuditAdmin();
insertAuthLogRows(3); // auth_log chain intact
// Seed a stale open incident for auth_log, exactly as VerifyAuditChains writes it.
$incidentId = DB::connection('pgsql_supplier')->table('incidents_log')->insertGetId([
'type' => 'other',
'severity' => 'high',
'summary' => 'Автоматически: разрыв hash-chain в партиции auth_log_y2026_m06 (таблица auth_log). Первый сломанный id=1, всего несовпадений=1.',
'root_cause' => null,
'started_at' => now()->subDay(),
'detected_at' => now()->subDay(),
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
]);
Artisan::call('audit:verify-chains');
$incident = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('id', $incidentId)
->first();
expect($incident->resolved_at)->not->toBeNull();
expect((string) $incident->root_cause)->toContain('auth_log');
});
test('breached table does not auto-resolve its open chain incident', function () {
$adminId = ensureAuditAdmin();
insertAuthLogRows(3);
// Seed an open incident for auth_log.
$incidentId = DB::connection('pgsql_supplier')->table('incidents_log')->insertGetId([
'type' => 'other',
'severity' => 'high',
'summary' => 'Автоматически: разрыв hash-chain в партиции auth_log_y2026_m06 (таблица auth_log).',
'root_cause' => null,
'started_at' => now()->subDay(),
'detected_at' => now()->subDay(),
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => now()->subDay(),
'updated_at' => now()->subDay(),
]);
// Tamper auth_log so its chain is broken at run time.
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
DB::table('auth_log')
->orderBy('id')
->limit(1)
->update(['ip_address' => '4.4.4.4']);
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
Artisan::call('audit:verify-chains');
$incident = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('id', $incidentId)
->first();
expect($incident->resolved_at)->toBeNull();
});
test('exit code is FAILURE on breach even when no active admin exists for incident FK', function () {
// Remove all active admins so recordIncident cannot write the FK row
DB::table('saas_admin_users')->update(['is_active' => false]);
insertAuthLogRows(2);
// Tamper
DB::statement('ALTER TABLE auth_log DISABLE TRIGGER USER');
DB::table('auth_log')
->orderBy('id')
->limit(1)
->update(['ip_address' => '9.9.9.9']);
DB::statement('ALTER TABLE auth_log ENABLE TRIGGER USER');
// Must FAIL even though the incident row cannot be written (no active admin)
$this->artisan('audit:verify-chains')->assertFailed();
// No incident row written (no active admin)
$count = DB::connection('pgsql_supplier')
->table('incidents_log')
->where('summary', 'like', '%chain%auth_log%')
->count();
expect($count)->toBe(0);
// 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();
});