6e1f5355b8
Task 4.1 Steps 1–7: legacy direct webhook channel DDL removal.
Migration 2026_05_24_140000_drop_legacy_webhook_artefacts:
- DROP TABLE webhook_log CASCADE (partitioned RANGE по received_at)
- DROP TABLE rejected_deals_log CASCADE
- ALTER TABLE tenants DROP COLUMN webhook_token, webhook_token_rotated_at
- DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'
NB: webhook_dedup_keys ОСТАВЛЕНА — используется CSV-каналом (HistoricalImportService).
Services fixed (не покрыты Phase 3):
- MonthlyPartitionManager::PARTITIONED_TABLES — убрана строка webhook_log
- PdErasureService::eraseSubject() — убрана секция 4 (SELECT/UPDATE webhook_log)
Factory + tests cleanup (webhook_token column gone):
- TenantFactory: убрано webhook_token из definition()
- 7 test files: убраны вставки webhook_token в DB::table('tenants')->insert(...)
- storage/_demo_split_tenants.php: убрана строка webhook_token
Schema v8.35:
- −2 таблицы (webhook_log partitioned + rejected_deals_log)
- −5 индексов (idx_webhook_log_*, idx_rejected_*, idx_tenants_webhook_token)
- −2 RLS-политики
- db/CHANGELOG_schema.md: запись v8.35
Tests updated:
- SchemaDeltaTest: 66 base tables / 120 indexes / 40 RLS policies
- PartitionsCreateMonthsTest: webhook_log убрана из regex / 48 skipped вместо 54
Smoke: 36/36 passed (RlsSmoke, AdminBilling, AdminPdSubject, PartitionsCreateMonths, SchemaDelta).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
431 lines
18 KiB
PHP
431 lines
18 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: per-scope validator must not false-positive on
|
|
// data with multiple tenants.
|
|
//
|
|
// 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.
|
|
// ===========================================================================
|
|
|
|
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
|
|
// ===========================================================================
|
|
|
|
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
|
|
// ===========================================================================
|
|
|
|
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);
|
|
});
|