diff --git a/app/app/Console/Commands/VerifyAuditChains.php b/app/app/Console/Commands/VerifyAuditChains.php index c1b0b9a0..732c4a79 100644 --- a/app/app/Console/Commands/VerifyAuditChains.php +++ b/app/app/Console/Commands/VerifyAuditChains.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Console\Commands; use App\Mail\AuditChainBreachMail; +use App\Services\Audit\AuditChainConfig; use Illuminate\Console\Command; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -83,166 +84,12 @@ class VerifyAuditChains extends Command protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах (per-partition)'; - /** - * Конфигурация таблиц: имя таблицы → [columns, partition_clause]. - * - * columns: список столбцов строго в порядке ordinal_position из db/schema.sql. - * Специальное значение '__log_hash__' — маркер позиции log_hash → NULL::bytea. - * - * partition_clause: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id), - * воспроизводящий RLS-scope триггера внутри одной партиции. - * Пустая строка = глобальная цепочка внутри партиции. - * - * @var array, partition: string}> - */ - private const TABLE_CONFIG = [ - // auth_log: - // RLS: actor_type='tenant_user' AND tenant_id = current_setting(...) - // Tenant-сессия видит только (actor_type='tenant_user', tenant_id=N). - // saas_admin-сессия BYPASSRLS — видит всё. - // Partition (actor_type, tenant_id) воспроизводит оба случая: - // каждая пара образует независимую цепочку. - 'auth_log' => [ - 'columns' => [ - 'id', - 'actor_type', - 'tenant_id', - 'user_id', - 'saas_admin_user_id', - 'email', - 'event', - 'ip_address', - 'user_agent', - 'failure_reason', - '__log_hash__', // log_hash → NULL::bytea - 'created_at', - ], - // global chain: auth_log пишется при ЛОГИНЕ под BYPASSRLS-роль - // (tenant ещё не установлен — пользователь не аутентифицирован), - // поэтому триггерный prev-SELECT видит ВСЕ строки → цепочка глобальная - // внутри данной партиции (эмпирически подтверждено прод-smoke). - 'partition' => '', - ], - - // activity_log: - // RLS: tenant_id = current_setting(...) — простая tenant-изоляция. - // Partition: tenant_id. - 'activity_log' => [ - 'columns' => [ - 'id', - 'tenant_id', - 'user_id', - 'deal_id', - 'event', - 'old_value', - 'new_value', - 'context', - 'ip_address', - 'user_agent', - '__log_hash__', // log_hash → NULL::bytea - 'created_at', - ], - 'partition' => 'PARTITION BY tenant_id', - ], - - // tenant_operations_log: - // RLS: tenant_id = current_setting(...) — простая tenant-изоляция. - // Partition: tenant_id. - 'tenant_operations_log' => [ - 'columns' => [ - 'id', - 'tenant_id', - 'user_id', - 'entity_type', - 'entity_id', - 'event', - 'payload_before', - 'payload_after', - 'ip_address', - 'user_agent', - '__log_hash__', // log_hash → NULL::bytea - 'created_at', - ], - 'partition' => 'PARTITION BY tenant_id', - ], - - // balance_transactions: - // RLS: tenant_id = current_setting(...) — простая tenant-изоляция. - // Partition: tenant_id. - 'balance_transactions' => [ - 'columns' => [ - 'id', - 'tenant_id', - 'type', - 'amount_rub', - 'amount_leads', - 'balance_rub_after', - 'balance_leads_after', - 'description', - 'related_type', - 'related_id', - 'user_id', - 'admin_user_id', - '__log_hash__', // log_hash → NULL::bytea - 'created_at', - ], - 'partition' => 'PARTITION BY tenant_id', - ], - - // pd_processing_log: - // RLS: tenant_id = current_setting(...) — простая tenant-изоляция. - // Partition: tenant_id. - 'pd_processing_log' => [ - 'columns' => [ - 'id', - 'tenant_id', - 'subject_type', - 'subject_id', - 'action', - 'purpose', - 'actor_tenant_user_id', - 'actor_admin_user_id', - 'ip_address', - '__log_hash__', // log_hash → NULL::bytea - 'created_at', - ], - 'partition' => 'PARTITION BY tenant_id', - ], - - // saas_admin_audit_log: - // Нет RLS-политики для tenant-ролей (REVOKE ALL FROM crm_app_user). - // Вставляет только crm_admin_user (BYPASSRLS) — триггер's SELECT - // видит ВСЕ строки партиции → цепочка глобальная внутри партиции. - // Partition: нет (пустая строка = ORDER BY id без PARTITION BY). - 'saas_admin_audit_log' => [ - 'columns' => [ - 'id', - 'admin_user_id', - 'action', - 'target_type', - 'target_id', - 'target_tenant_id', - 'payload_before', - 'payload_after', - 'reason', - 'ip_address', - 'user_agent', - 'requires_approval', - 'approved_by', - 'approved_at', - '__log_hash__', // log_hash → NULL::bytea - 'created_at', - ], - 'partition' => '', // global chain within partition — inserting role is BYPASSRLS - ], - ]; - public function handle(): int { $anyBreach = false; $now = Carbon::now(); - foreach (self::TABLE_CONFIG as $table => $config) { + foreach (AuditChainConfig::TABLES as $table => $config) { // Get all partitions for this table via pg_inherits. $partitions = $this->listPartitions($table); @@ -252,7 +99,7 @@ class VerifyAuditChains extends Command } foreach ($partitions as $partitionName) { - $breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']); + $breaches = $this->checkPartition($partitionName, $table, $config['partition']); if (empty($breaches)) { $this->line(" ✓ {$partitionName}: chain intact"); @@ -321,12 +168,11 @@ class VerifyAuditChains extends Command * где ROW(...) имеет NULL::bytea на позиции log_hash. * 4. Возвращает строки, где stored IS DISTINCT FROM recomputed. * - * @param list $columns * @return list */ - private function checkPartition(string $partitionName, array $columns, string $partition): array + private function checkPartition(string $partitionName, string $table, string $partition): array { - $rowExpr = $this->buildRowExpression($columns); + $rowExpr = AuditChainConfig::rowExpression($table); // Build OVER clause: with or without PARTITION BY depending on table's RLS scope. $overClause = $partition !== '' @@ -366,25 +212,6 @@ class VerifyAuditChains extends Command return $results; } - /** - * Строит SQL-выражение ROW(col1, col2, ..., NULL::bytea, ..., coln) - * с NULL::bytea на месте log_hash. - * - * Пример для auth_log: - * ROW(t.id, t.actor_type, t.tenant_id, ..., NULL::bytea, t.created_at) - * - * @param list $columns - */ - private function buildRowExpression(array $columns): string - { - $parts = []; - foreach ($columns as $col) { - $parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}"; - } - - return 'ROW('.implode(', ', $parts).')'; - } - /** * Вставляет запись в incidents_log (через pgsql_supplier BYPASSRLS). * Дедупликация: не создаёт повторный инцидент для той же таблицы, diff --git a/app/tests/Feature/Audit/VerifyAuditChainsTest.php b/app/tests/Feature/Audit/VerifyAuditChainsTest.php new file mode 100644 index 00000000..1e447026 --- /dev/null +++ b/app/tests/Feature/Audit/VerifyAuditChainsTest.php @@ -0,0 +1,65 @@ +toContain('auth_log') + ->toContain('activity_log') + ->toContain('tenant_operations_log') + ->toContain('balance_transactions') + ->toContain('pd_processing_log') + ->toContain('saas_admin_audit_log'); + + expect(count($tables))->toBe(6); +}); + +it('AuditChainConfig::rowExpression builds ROW expression with NULL::bytea at log_hash position', function (): void { + $expr = AuditChainConfig::rowExpression('auth_log'); + + expect($expr)->toStartWith('ROW(') + ->toContain('NULL::bytea') + ->not->toContain('t.__log_hash__'); +}); + +it('AuditChainConfig::rowExpression produces same result for all six tables', function (): void { + foreach (array_keys(AuditChainConfig::TABLES) as $table) { + $expr = AuditChainConfig::rowExpression($table); + + expect($expr) + ->toStartWith('ROW(') + ->toContain('NULL::bytea') + ->not->toContain('t.__log_hash__'); + } +}); + +it('AuditChainConfig::rowExpression throws for unknown table', function (): void { + AuditChainConfig::rowExpression('nonexistent_table'); +})->throws(\InvalidArgumentException::class); + +it('VerifyAuditChains command class exists and is registered', function (): void { + expect(class_exists(\App\Console\Commands\VerifyAuditChains::class))->toBeTrue(); +}); + +it('VerifyAuditChains does not have private TABLE_CONFIG const after ADR-018 refactor', function (): void { + $reflection = new ReflectionClass(\App\Console\Commands\VerifyAuditChains::class); + $constants = $reflection->getReflectionConstants(); + $names = array_map(fn ($c) => $c->getName(), $constants); + + // After Task 2 refactor, TABLE_CONFIG should be removed (delegated to AuditChainConfig::TABLES) + expect($names)->not->toContain('TABLE_CONFIG'); +});