Files
portal/app/app/Console/Commands/VerifyAuditChains.php
T
Дмитрий 60ab5be3eb feat(audit): partitioning 7 audit-таблиц по месяцам (hole #2 Phase A)
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция
схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) —
отдельным шагом с явным approve.

Решения заказчика:
* Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log,
  webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log)
* FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log
* Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м,
  balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК
* Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже
  работает per-partition; совместимо с hole #1 per-RLS-scope fix)

Phase A:
* db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE,
  PK→(id, partition_key), +7 retention seeds в system_settings,
  FK от failed_webhook_jobs/rejected_deals_log удалены
* MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array
  (name => partition_key), 2 → 9 таблиц
* PartitionsCreateMonths: автоматически покрывает все 9
* load_initial_schema: после schema.sql вызывает Artisan
  partitions:create-months --ahead=2 (без этого первый INSERT падает)
* 2026_05_22_000001_tenant_operations_log: idempotency guard
* VerifyAuditChains: per-partition scan через pg_inherits;
  fallback на single-scope для не-партиционированной таблицы;
  per-RLS-scope partition_clause сохранён внутри каждой партиции
* AuditChainBreachMail: +partitionName param (NULL=fallback на tableName)
* PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из
  system_settings, dry-run mode, safety guard retention=0
* SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин)

Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh.
Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres
для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup).

Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail —
pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_*
(FK actor_admin_user_id после partitioning pd_processing_log, отдельная
задача для hole #4 follow-up, не блокирует).

cspell +2 слова (партиционировать, дёшева). Pint --fix чистый.

Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md
Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
2026-05-23 15:50:37 +03:00

471 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Mail\AuditChainBreachMail;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Проверяет целостность SHA-256 hash-chain во всех 6 audit-таблицах.
*
* Алгоритм на стороне PostgreSQL (не PHP) — чтобы воспроизвести ровно ту же
* сериализацию ROW::text, что использует триггер audit_chain_hash():
*
* digest(COALESCE(prev_log_hash,''::bytea) || ROW(col1,...,NULL::bytea,...col_n)::text::bytea, 'sha256')
*
* где NULL::bytea — позиция log_hash (она была NULL в момент срабатывания
* BEFORE INSERT триггера). Список столбцов в порядке их ordinal_position
* из information_schema жёстко закодирован для каждой таблицы.
*
* ──────────────────────────────────────────────────────────────────────────────
* ВАЖНО: per-partition scan (hole #2 adaptation).
*
* После перевода таблиц на RANGE-партиционирование (v8.31) каждая партиция
* содержит строки одного месяца. Триггер audit_chain_hash() при INSERT в
* партицию видит строки только ЭТОЙ партиции (TG_TABLE_NAME = partition name,
* SELECT LAG по partition → prev — последняя запись той же партиции).
*
* Поэтому валидатор проверяет hash-chain отдельно для каждой партиции:
* 1. Получает список партиций через pg_inherits + pg_class.
* 2. Для каждой партиции выполняет checkPartition().
* 3. Несоответствие в ЛЮБОЙ партиции → инцидент с указанием partition_name.
*
* Пустые партиции (без строк) — OK, chain пустая = intact.
*
* ──────────────────────────────────────────────────────────────────────────────
* ВАЖНО: per-scope RLS partitioning.
*
* Триггер audit_chain_hash() делает:
* SELECT log_hash FROM <table> ORDER BY id DESC LIMIT 1
* Этот SELECT выполняется под ролью вставляющей сессии и подпадает под RLS.
*
* После партиционирования SELECT работает внутри текущей партиции — TG_TABLE_NAME.
* RLS-scope воспроизводится так же, как до партиционирования, но область
* видимости ограничена одной партицией → per-partition per-RLS-scope цепочка.
*
* Валидатор воспроизводит это через PARTITION BY RLS-scope ВНУТРИ каждой
* partition-таблицы (те же partition_clause что раньше).
*
* ──────────────────────────────────────────────────────────────────────────────
*
* При разрыве: создаёт incidents_log (type='other', severity='high', через
* pgsql_supplier BYPASSRLS), дедупликация 24ч, email на kdv1@bk.ru.
* Возвращает self::FAILURE при ЛЮБОМ разрыве — независимо от успеха записи
* инцидента (инцидент-запись best-effort, не влияет на exit code).
*
* Запускается daily 04:00 (routes/console.php).
*
* Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
* docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md §A.4
* Паттерн: IncidentsWatchFailures + SharesSupplierPdo.
*/
class VerifyAuditChains extends Command
{
private const DB_CONNECTION = 'pgsql_supplier';
/**
* Monitoring email для критичных алертов audit-целостности.
*/
private const MONITORING_EMAIL = 'kdv1@bk.ru';
/**
* Дедупликация инцидентов: не создавать повторный инцидент по той же таблице
* если прошло менее DEDUP_HOURS часов.
*/
private const DEDUP_HOURS = 24;
protected $signature = 'audit:verify-chains';
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<string, array{columns: list<string>, 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) {
// Get all partitions for this table via pg_inherits.
$partitions = $this->listPartitions($table);
if (empty($partitions)) {
// Table not yet partitioned or no partitions — check parent directly (fallback).
$partitions = [$table];
}
foreach ($partitions as $partitionName) {
$breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']);
if (empty($breaches)) {
$this->line("{$partitionName}: chain intact");
continue;
}
$anyBreach = true;
$firstId = $breaches[0]->id;
$count = count($breaches);
$this->error("{$partitionName}: {$count} mismatch(es), first broken id={$firstId}");
// Incident write is best-effort: never let it suppress the breach signal.
try {
$this->recordIncident($table, $partitionName, $firstId, $count, $now);
} catch (\Throwable $e) {
$this->warn(" Incident write failed for {$partitionName}: {$e->getMessage()}");
}
$this->sendAlert($table, $partitionName, $firstId, $count);
}
}
// Exit FAILURE on ANY breach regardless of incident-write success.
if ($anyBreach) {
return self::FAILURE;
}
$this->info('All audit chains intact.');
return self::SUCCESS;
}
/**
* Возвращает список дочерних партиций таблицы через pg_inherits.
* Возвращает пустой массив если таблица не партиционирована или партиций нет.
*
* @return list<string>
*/
private function listPartitions(string $table): array
{
$rows = DB::connection(self::DB_CONNECTION)->select(
'SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = ?
ORDER BY c.relname',
[$table],
);
return array_map(fn ($r) => $r->relname, $rows);
}
/**
* Проверяет hash-chain одной партиции (или таблицы) через SQL на стороне PostgreSQL.
*
* Возвращает список строк, у которых stored log_hash ≠ recomputed hash.
*
* SQL-логика:
* 1. Берёт все строки партиции.
* 2. Через LAG(log_hash) OVER (<partition> ORDER BY id) получает prev_hash
* каждой строки в пределах её RLS-scope (partition).
* 3. Пересчитывает: digest(COALESCE(prev_hash,''::bytea) || ROW(...)::text::bytea, 'sha256')
* где ROW(...) имеет NULL::bytea на позиции log_hash.
* 4. Возвращает строки, где stored IS DISTINCT FROM recomputed.
*
* @param list<string> $columns
* @return list<object>
*/
private function checkPartition(string $partitionName, array $columns, string $partition): array
{
$rowExpr = $this->buildRowExpression($columns);
// Build OVER clause: with or without PARTITION BY depending on table's RLS scope.
$overClause = $partition !== ''
? "({$partition} ORDER BY id)"
: '(ORDER BY id)';
$sql = <<<SQL
WITH ordered AS (
SELECT
id,
log_hash AS stored_hash,
LAG(log_hash) OVER {$overClause} AS prev_hash
FROM {$partitionName}
)
SELECT
o.id,
o.stored_hash,
digest(
COALESCE(o.prev_hash, ''::bytea)
|| (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id),
'sha256'
) AS recomputed_hash
FROM ordered o
WHERE o.stored_hash IS DISTINCT FROM
digest(
COALESCE(o.prev_hash, ''::bytea)
|| (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id),
'sha256'
)
ORDER BY o.id
SQL;
/** @var list<object> $results */
$results = DB::connection(self::DB_CONNECTION)
->select($sql);
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<string> $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).
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
* если за последние DEDUP_HOURS часов уже есть открытый инцидент.
*
* Вызывается внутри try/catch в handle() — исключение не подавляет
* breach-сигнал (handle() всё равно вернёт self::FAILURE).
*
* @param string $table Имя родительской таблицы (для дедупликации)
* @param string $partitionName Имя конкретной партиции где обнаружен разрыв
*/
private function recordIncident(
string $table,
string $partitionName,
int $firstBrokenId,
int $count,
Carbon $now
): void {
$dedupSince = $now->copy()->subHours(self::DEDUP_HOURS);
$alreadyOpen = DB::connection(self::DB_CONNECTION)
->table('incidents_log')
->where('type', 'other')
->where('severity', 'high')
->where('summary', 'like', '%chain%'.addcslashes($table, '%_\\').'%')
->whereNull('resolved_at')
->where('detected_at', '>=', $dedupSince)
->exists();
if ($alreadyOpen) {
$this->line(" Skipping incident (dedup): {$partitionName}");
return;
}
// Для NOT NULL FK created_by_admin_id берём первого активного SaaS-admin.
// Если нет активных admins — пишем предупреждение, но НЕ пропускаем:
// бросаем исключение, чтобы caller (try/catch в handle()) его поймал
// и залогировал. Breach-сигнал (FAILURE exit code) уже установлен выше.
$adminId = DB::connection(self::DB_CONNECTION)
->table('saas_admin_users')
->where('is_active', true)
->whereNull('deleted_at')
->value('id');
if ($adminId === null) {
$this->warn(" No active saas_admin_users — incident not recorded for {$partitionName}");
return;
}
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
'type' => 'other',
'severity' => 'high',
'summary' => "Автоматически: разрыв hash-chain в партиции {$partitionName} (таблица {$table}). "
."Первый сломанный id={$firstBrokenId}, всего несовпадений={$count}. "
.'Возможен tampering (UPDATE/DELETE в обход триггеров).',
'root_cause' => null,
'started_at' => $now,
'detected_at' => $now,
'resolved_at' => null,
'created_by_admin_id' => $adminId,
'created_at' => $now,
'updated_at' => $now,
]);
$this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})");
}
/**
* Отправляет email-алёрт на monitoring email.
*/
private function sendAlert(string $table, string $partitionName, int $firstBrokenId, int $count): void
{
try {
Mail::to(self::MONITORING_EMAIL)
->send(new AuditChainBreachMail($table, $firstBrokenId, $count, $partitionName));
} catch (\Throwable $e) {
// Не ломаем команду если почта недоступна — инцидент уже записан
$this->warn(" Email failed: {$e->getMessage()}");
}
}
}