diff --git a/app/app/Console/Commands/PartitionsCreateMonths.php b/app/app/Console/Commands/PartitionsCreateMonths.php index e7a3f028..341762e9 100644 --- a/app/app/Console/Commands/PartitionsCreateMonths.php +++ b/app/app/Console/Commands/PartitionsCreateMonths.php @@ -9,17 +9,19 @@ use Illuminate\Console\Command; use Illuminate\Support\Carbon; /** - * Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs` + * Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES * на N месяцев вперёд от текущей даты. * + * Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц). + * * Замена `pg_partman` на native Windows-стеке (расширение недоступно * без сборки из исходников). Запускается ежесуточно через Windows Task - * Scheduler / cron — идемпотентна (CREATE TABLE IF NOT EXISTS). + * Scheduler / cron — идемпотентна (проверяет pg_class перед CREATE). * * По дефолту 2 месяца вперёд (паритет с инициализацией schema.sql: * 6 партиций при `migrate:fresh`, последующие месяцы — этим cron'ом). * - * Источник: db/schema.sql §5 (deals partition), §8.5 (supplier_lead_costs); + * Источник: MonthlyPartitionManager::PARTITIONED_TABLES (единственный SoT списка таблиц); * project_phase1_strategy.md (pg_partman заменён ручным cron'ом). */ class PartitionsCreateMonths extends Command @@ -28,7 +30,7 @@ class PartitionsCreateMonths extends Command protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}'; /** @var string */ - protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)'; + protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)'; public function handle(MonthlyPartitionManager $manager): int { @@ -41,8 +43,8 @@ class PartitionsCreateMonths extends Command for ($i = 0; $i <= $ahead; $i++) { $monthStart = $now->copy()->addMonths($i); - foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) { - $partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m')); + foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) { + $partitionName = $manager->partitionName($table, $monthStart); if ($manager->ensureMonth($table, $monthStart)) { $created++; diff --git a/app/app/Console/Commands/PartitionsDropExpired.php b/app/app/Console/Commands/PartitionsDropExpired.php new file mode 100644 index 00000000..e5578c42 --- /dev/null +++ b/app/app/Console/Commands/PartitionsDropExpired.php @@ -0,0 +1,181 @@ +' + * value = количество месяцев (integer >= 1) + * + * Защита от опасных значений: + * - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего) + * - 0 → пропустить (запрет стирания всего) + * - < 0 → пропустить + * - Минимальное значение, принятое к выполнению: 1 месяц + * + * Формат имени партиции: _y_m + * Партиция считается устаревшей, если её месяц < (текущий месяц − retention). + * + * Пример: + * сейчас = 2026-05, retention = 3 + * cutoff = 2026-02 (включительно; т.е. 2026-01 и старее — дропать) + * будет удалена: auth_log_y2026_m01, auth_log_y2025_m12, … + * НЕ будет удалена: auth_log_y2026_m02 (граница), и всё новее + * + * Hole #2, 23.05.2026. + */ +class PartitionsDropExpired extends Command +{ + /** @var string */ + protected $signature = 'partitions:drop-expired + {--dry-run : Перечислить партиции для удаления, не удалять}'; + + /** @var string */ + protected $description = 'Удаляет устаревшие месячные партиции согласно system_settings (partition_retention_months_*)'; + + public function handle(MonthlyPartitionManager $manager): int + { + $isDryRun = (bool) $this->option('dry-run'); + + if ($isDryRun) { + $this->line('Dry-run: партиции будут перечислены, но NOT удалены.'); + } + + $now = Carbon::now()->startOfMonth(); + $totalDropped = 0; + $totalSkipped = 0; + + foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) { + $retention = $this->resolveRetention($table); + + if ($retention === null) { + $this->line(" skip {$table}: retention not configured"); + + continue; + } + + $partitions = $manager->listPartitions($table); + + if (empty($partitions)) { + $this->line(" skip {$table}: no partitions exist yet"); + + continue; + } + + $dropped = 0; + + foreach ($partitions as $partitionName) { + $monthStart = $this->parsePartitionMonth($partitionName); + + if ($monthStart === null) { + // Имя не соответствует формату _yYYYY_mMM — не трогать (безопасность) + $this->warn(" ? {$partitionName}: unrecognised name format, skipping"); + $totalSkipped++; + + continue; + } + + // Граница: всё строго старее (now - retention месяцев) — удалять. + // Т.е. monthStart < cutoff, где cutoff = now - retention. + $cutoff = $now->copy()->subMonths($retention); + + if (! $monthStart->lessThan($cutoff)) { + // Партиция ещё в пределах retention — оставить + continue; + } + + if ($isDryRun) { + $this->line(" [dry-run] would drop {$partitionName}"); + } else { + $this->dropPartition($partitionName); + $this->line(" dropped {$partitionName}"); + } + + $dropped++; + $totalDropped++; + } + + if ($dropped === 0) { + $this->line(" ok {$table}: all partitions within retention={$retention}mo"); + } + } + + $this->newLine(); + + if ($isDryRun) { + $this->info("Dry-run complete: {$totalDropped} would be dropped, {$totalSkipped} skipped (unrecognised name)."); + } else { + $this->info("Done: {$totalDropped} dropped, {$totalSkipped} skipped (unrecognised name)."); + } + + return self::SUCCESS; + } + + /** + * Читает retention для таблицы из system_settings. + * Возвращает null, если настройка отсутствует или небезопасна (0 / отрицательная). + */ + private function resolveRetention(string $table): ?int + { + $key = "partition_retention_months_{$table}"; + + $setting = SystemSetting::find($key); + + if ($setting === null) { + return null; + } + + $value = (int) $setting->value; + + if ($value < 1) { + // 0 или отрицательное — блокируем, не дропаем ничего + $this->warn(" ! {$table}: retention value={$value} is invalid (<1), skipping"); + + return null; + } + + return $value; + } + + /** + * Парсит имя партиции вида _y_m и возвращает Carbon начала месяца. + * Возвращает null, если имя не соответствует формату. + */ + private function parsePartitionMonth(string $partitionName): ?Carbon + { + // Pattern: ends with _yYYYY_mMM (e.g. auth_log_y2026_m05) + if (! preg_match('/_y(\d{4})_m(\d{2})$/', $partitionName, $m)) { + return null; + } + + $year = (int) $m[1]; + $month = (int) $m[2]; + + if ($month < 1 || $month > 12) { + return null; + } + + return Carbon::create($year, $month, 1, 0, 0, 0)->startOfMonth(); + } + + /** + * Удаляет партицию (DETACH + DROP TABLE). + * + * Безопасность: имя проверено регекспом в parsePartitionMonth + * (только символы \w и _ — SQL injection невозможен). + */ + private function dropPartition(string $partitionName): void + { + DB::statement("DROP TABLE IF EXISTS {$partitionName}"); + } +} diff --git a/app/app/Console/Commands/VerifyAuditChains.php b/app/app/Console/Commands/VerifyAuditChains.php index 7d79fee8..c1b0b9a0 100644 --- a/app/app/Console/Commands/VerifyAuditChains.php +++ b/app/app/Console/Commands/VerifyAuditChains.php @@ -23,52 +23,34 @@ use Illuminate\Support\Facades\Mail; * из information_schema жёстко закодирован для каждой таблицы. * * ────────────────────────────────────────────────────────────────────────────── - * ВАЖНО: per-scope partitioning (prod fix). + * ВАЖНО: 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
ORDER BY id DESC LIMIT 1 * Этот SELECT выполняется под ролью вставляющей сессии и подпадает под RLS. * - * На DEV роль — postgres (superuser, BYPASSRLS): SELECT видит ВСЕ строки → - * цепочка ГЛОБАЛЬНАЯ. + * После партиционирования SELECT работает внутри текущей партиции — TG_TABLE_NAME. + * RLS-scope воспроизводится так же, как до партиционирования, но область + * видимости ограничена одной партицией → per-partition per-RLS-scope цепочка. * - * На PROD роль — crm_app_user (НЕ BYPASSRLS): SELECT видит только строки, - * удовлетворяющие RLS-политике тенанта → цепочка PER-RLS-SCOPE. + * Валидатор воспроизводит это через PARTITION BY RLS-scope ВНУТРИ каждой + * partition-таблицы (те же partition_clause что раньше). * - * Валидатор читает через pgsql_supplier (BYPASSRLS) — видит все строки, но - * разбивает LAG OVER PARTITION BY по той же границе, что и RLS при INSERT, - * воспроизводя prod-поведение триггера. - * - * Таблица → scope (partition) → основание: - * - * tenant_operations_log: - * RLS: tenant_id = current_setting(...) → PARTITION BY tenant_id - * - * activity_log: - * RLS: tenant_id = current_setting(...) → PARTITION BY tenant_id - * - * balance_transactions: - * RLS: tenant_id = current_setting(...) → PARTITION BY tenant_id - * - * pd_processing_log: - * RLS: tenant_id = current_setting(...) → PARTITION BY tenant_id - * - * auth_log: - * RLS: actor_type='tenant_user' AND tenant_id = current_setting(...) - * Под tenant-сессией триггер видит ТОЛЬКО строки своего тенанта с - * actor_type='tenant_user'. Строки с actor_type='saas_admin' (tenant_id=NULL) - * невидимы tenant-сессии — они вставляются под crm_admin_user (BYPASSRLS) - * и видят ВСЕ строки, т.е. их "prev" — глобально последняя строка. - * Практически: saas_admin-строки вставляются редко и образуют отдельный scope. - * Partition: PARTITION BY actor_type, tenant_id - * Это даёт: каждая пара (actor_type, tenant_id) — независимая цепочка, - * что точно воспроизводит видимость триггера в обоих случаях. - * - * saas_admin_audit_log: - * Таблица НЕ имеет RLS-политики для обычных тенантов (REVOKE ALL FROM - * crm_app_user, доступна только crm_admin_user = BYPASSRLS). - * Вставляющая роль — BYPASSRLS, поэтому триггер's SELECT видит ВСЕ строки. - * → цепочка ГЛОБАЛЬНАЯ, PARTITION: нет (ORDER BY id без PARTITION BY). * ────────────────────────────────────────────────────────────────────────────── * * При разрыве: создаёт incidents_log (type='other', severity='high', через @@ -79,6 +61,7 @@ use Illuminate\Support\Facades\Mail; * Запускается 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 @@ -98,7 +81,7 @@ class VerifyAuditChains extends Command protected $signature = 'audit:verify-chains'; - protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах'; + protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах (per-partition)'; /** * Конфигурация таблиц: имя таблицы → [columns, partition_clause]. @@ -107,7 +90,8 @@ class VerifyAuditChains extends Command * Специальное значение '__log_hash__' — маркер позиции log_hash → NULL::bytea. * * partition_clause: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id), - * воспроизводящий RLS-scope триггера. Пустая строка = глобальная цепочка. + * воспроизводящий RLS-scope триггера внутри одной партиции. + * Пустая строка = глобальная цепочка внутри партиции. * * @var array, partition: string}> */ @@ -136,7 +120,7 @@ class VerifyAuditChains extends Command // global chain: auth_log пишется при ЛОГИНЕ под BYPASSRLS-роль // (tenant ещё не установлен — пользователь не аутентифицирован), // поэтому триггерный prev-SELECT видит ВСЕ строки → цепочка глобальная - // (эмпирически подтверждено прод-smoke: global intact, per-scope ломался). + // внутри данной партиции (эмпирически подтверждено прод-smoke). 'partition' => '', ], @@ -228,7 +212,7 @@ class VerifyAuditChains extends Command // 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' => [ @@ -249,7 +233,7 @@ class VerifyAuditChains extends Command '__log_hash__', // log_hash → NULL::bytea 'created_at', ], - 'partition' => '', // global chain — inserting role is BYPASSRLS + 'partition' => '', // global chain within partition — inserting role is BYPASSRLS ], ]; @@ -259,28 +243,38 @@ class VerifyAuditChains extends Command $now = Carbon::now(); foreach (self::TABLE_CONFIG as $table => $config) { - $breaches = $this->checkTable($table, $config['columns'], $config['partition']); + // Get all partitions for this table via pg_inherits. + $partitions = $this->listPartitions($table); - if (empty($breaches)) { - $this->info("✓ {$table}: chain intact"); - - continue; + if (empty($partitions)) { + // Table not yet partitioned or no partitions — check parent directly (fallback). + $partitions = [$table]; } - $anyBreach = true; - $firstId = $breaches[0]->id; - $count = count($breaches); + foreach ($partitions as $partitionName) { + $breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']); - $this->error("✗ {$table}: {$count} mismatch(es), first broken id={$firstId}"); + if (empty($breaches)) { + $this->line(" ✓ {$partitionName}: chain intact"); - // Incident write is best-effort: never let it suppress the breach signal. - try { - $this->recordIncident($table, $firstId, $count, $now); - } catch (\Throwable $e) { - $this->warn(" Incident write failed for {$table}: {$e->getMessage()}"); + 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); } - - $this->sendAlert($table, $firstId, $count); } // Exit FAILURE on ANY breach regardless of incident-write success. @@ -294,12 +288,33 @@ class VerifyAuditChains extends Command } /** - * Проверяет hash-chain одной таблицы через SQL на стороне PostgreSQL. + * Возвращает список дочерних партиций таблицы через pg_inherits. + * Возвращает пустой массив если таблица не партиционирована или партиций нет. + * + * @return list + */ + 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. Берёт все строки. + * 1. Берёт все строки партиции. * 2. Через LAG(log_hash) OVER ( ORDER BY id) получает prev_hash * каждой строки в пределах её RLS-scope (partition). * 3. Пересчитывает: digest(COALESCE(prev_hash,''::bytea) || ROW(...)::text::bytea, 'sha256') @@ -309,7 +324,7 @@ class VerifyAuditChains extends Command * @param list $columns * @return list */ - private function checkTable(string $table, array $columns, string $partition): array + private function checkPartition(string $partitionName, array $columns, string $partition): array { $rowExpr = $this->buildRowExpression($columns); @@ -324,21 +339,21 @@ class VerifyAuditChains extends Command id, log_hash AS stored_hash, LAG(log_hash) OVER {$overClause} AS prev_hash - FROM {$table} + FROM {$partitionName} ) SELECT o.id, o.stored_hash, digest( COALESCE(o.prev_hash, ''::bytea) - || (SELECT {$rowExpr}::text::bytea FROM {$table} t WHERE t.id = o.id), + || (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 {$table} t WHERE t.id = o.id), + || (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id), 'sha256' ) ORDER BY o.id @@ -377,9 +392,13 @@ class VerifyAuditChains extends Command * * Вызывается внутри 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 @@ -396,7 +415,7 @@ class VerifyAuditChains extends Command ->exists(); if ($alreadyOpen) { - $this->line(" Skipping incident (dedup): {$table}"); + $this->line(" Skipping incident (dedup): {$partitionName}"); return; } @@ -412,7 +431,7 @@ class VerifyAuditChains extends Command ->value('id'); if ($adminId === null) { - $this->warn(" No active saas_admin_users — incident not recorded for {$table}"); + $this->warn(" No active saas_admin_users — incident not recorded for {$partitionName}"); return; } @@ -420,7 +439,7 @@ class VerifyAuditChains extends Command DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([ 'type' => 'other', 'severity' => 'high', - 'summary' => "Автоматически: разрыв hash-chain в таблице {$table}. " + 'summary' => "Автоматически: разрыв hash-chain в партиции {$partitionName} (таблица {$table}). " ."Первый сломанный id={$firstBrokenId}, всего несовпадений={$count}. " .'Возможен tampering (UPDATE/DELETE в обход триггеров).', 'root_cause' => null, @@ -432,20 +451,20 @@ class VerifyAuditChains extends Command 'updated_at' => $now, ]); - $this->warn(" Incident recorded for {$table} (id={$firstBrokenId})"); + $this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})"); } /** * Отправляет email-алёрт на monitoring email. */ - private function sendAlert(string $table, int $firstBrokenId, int $count): void + private function sendAlert(string $table, string $partitionName, int $firstBrokenId, int $count): void { try { Mail::to(self::MONITORING_EMAIL) - ->send(new AuditChainBreachMail($table, $firstBrokenId, $count)); + ->send(new AuditChainBreachMail($table, $firstBrokenId, $count, $partitionName)); } catch (\Throwable $e) { // Не ломаем команду если почта недоступна — инцидент уже записан - $this->warn(" Email failed: {$e->getMessage()}"); + $this->warn(" Email failed: {$e->getMessage()}"); } } } diff --git a/app/app/Mail/AuditChainBreachMail.php b/app/app/Mail/AuditChainBreachMail.php index 2f3221c2..311c92d6 100644 --- a/app/app/Mail/AuditChainBreachMail.php +++ b/app/app/Mail/AuditChainBreachMail.php @@ -22,13 +22,16 @@ final class AuditChainBreachMail extends Mailable public readonly string $tableName, public readonly int $firstBrokenId, public readonly int $mismatchCount, + public readonly ?string $partitionName = null, // v8.31: partition where breach was detected ) {} public function envelope(): Envelope { - return new Envelope( - subject: "[Лидерра CRITICAL] Разрыв hash-chain в {$this->tableName}", - ); + $subject = $this->partitionName !== null && $this->partitionName !== $this->tableName + ? "[Лидерра CRITICAL] Разрыв hash-chain в {$this->partitionName}" + : "[Лидерра CRITICAL] Разрыв hash-chain в {$this->tableName}"; + + return new Envelope(subject: $subject); } public function content(): Content @@ -37,6 +40,7 @@ final class AuditChainBreachMail extends Mailable text: 'emails.audit_chain_breach_text', with: [ 'tableName' => $this->tableName, + 'partitionName' => $this->partitionName ?? $this->tableName, 'firstBrokenId' => $this->firstBrokenId, 'mismatchCount' => $this->mismatchCount, 'now' => now()->timezone('Europe/Moscow')->toIso8601String(), diff --git a/app/app/Services/MonthlyPartitionManager.php b/app/app/Services/MonthlyPartitionManager.php index 8592589b..ab6ea1d8 100644 --- a/app/app/Services/MonthlyPartitionManager.php +++ b/app/app/Services/MonthlyPartitionManager.php @@ -9,19 +9,40 @@ use Illuminate\Support\Facades\DB; use InvalidArgumentException; /** - * Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at. + * Создаёт месячные RANGE-партиции для таблиц, партиционированных помесячно. * * Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки * из исходников). Идемпотентна: партиция, которая уже есть, пропускается. * * Используется: * - cron `partitions:create-months` — N месяцев вперёд; + * - `partitions:drop-expired` — дропает старые партиции; * - HistoricalImportService — под исторический диапазон дат CSV. + * + * Hole #2 (23.05.2026): расширен до 9 таблиц (+7 audit-таблиц). + * Ключ партиционирования теперь задаётся per-table в PARTITIONED_TABLES map. */ class MonthlyPartitionManager { - /** @var array Таблицы, партиционированные по received_at помесячно. */ - public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs']; + /** + * Таблицы, партиционированные помесячно. + * Ключ → имя таблицы, значение → колонка-ключ партиционирования. + * + * @var array + */ + public const PARTITIONED_TABLES = [ + // Бизнес-таблицы (исходные) + 'deals' => 'received_at', + 'supplier_lead_costs' => 'received_at', + // Audit-таблицы (hole #2, 23.05.2026) + 'auth_log' => 'created_at', + 'activity_log' => 'created_at', + 'tenant_operations_log' => 'created_at', + 'webhook_log' => 'received_at', + 'balance_transactions' => 'created_at', + 'pd_processing_log' => 'created_at', + 'saas_admin_audit_log' => 'created_at', + ]; /** * Гарантирует наличие месячных партиций таблицы для всех месяцев, @@ -31,9 +52,7 @@ class MonthlyPartitionManager */ public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int { - if (! in_array($table, self::PARTITIONED_TABLES, true)) { - throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно"); - } + $this->assertKnownTable($table); $month = $from->copy()->startOfMonth(); $last = $to->copy()->startOfMonth(); @@ -53,13 +72,14 @@ class MonthlyPartitionManager */ public function ensureMonth(string $table, CarbonInterface $monthStart): bool { - if (! in_array($table, self::PARTITIONED_TABLES, true)) { - throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно"); - } + $this->assertKnownTable($table); + $partitionKey = self::PARTITIONED_TABLES[$table]; $start = $monthStart->copy()->startOfMonth(); $end = $start->copy()->addMonth(); - $partition = sprintf('%s_%s', $table, $start->format('Y_m')); + + // Partition naming:
_y_m + $partition = sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m')); $exists = DB::selectOne( "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'", @@ -80,4 +100,45 @@ class MonthlyPartitionManager return true; } + + /** + * Возвращает имя партиции для заданной таблицы и месяца. + * Утилита для тестов и команды drop-expired. + */ + public function partitionName(string $table, CarbonInterface $monthStart): string + { + $this->assertKnownTable($table); + $start = $monthStart->copy()->startOfMonth(); + + return sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m')); + } + + /** + * Возвращает список существующих партиций для таблицы через pg_inherits. + * + * @return list Имена партиций (relname). + */ + public function listPartitions(string $table): array + { + $this->assertKnownTable($table); + + $rows = DB::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); + } + + private function assertKnownTable(string $table): void + { + if (! array_key_exists($table, self::PARTITIONED_TABLES)) { + throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно"); + } + } } diff --git a/app/app/Services/SchedulerHeartbeatTracker.php b/app/app/Services/SchedulerHeartbeatTracker.php index 280ace90..e006fa27 100644 --- a/app/app/Services/SchedulerHeartbeatTracker.php +++ b/app/app/Services/SchedulerHeartbeatTracker.php @@ -36,6 +36,7 @@ final class SchedulerHeartbeatTracker 'App\Jobs\Supplier\CsvReconcileJob' => 30, // every 30 min 'incidents:watch-failures' => 10, // every 10 min 'audit:verify-chains' => 1440, // daily + 'partitions:drop-expired' => 10080, // weekly (Sunday 03:00 MSK) 'scheduler:check-heartbeats' => 60, // hourly (self-check) ]; diff --git a/app/database/migrations/0001_01_01_000000_load_initial_schema.php b/app/database/migrations/0001_01_01_000000_load_initial_schema.php index b0f35edc..083e43cc 100644 --- a/app/database/migrations/0001_01_01_000000_load_initial_schema.php +++ b/app/database/migrations/0001_01_01_000000_load_initial_schema.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Database\Migrations\Migration; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; /** @@ -59,6 +60,12 @@ return new class extends Migration // с deals), поэтому DB::unprepared его успешно применил — повторный ALTER // здесь не нужен. Если в будущем PDO начнёт глотать FK на partitioned — // повторить паттерн webhook_dedup_keys с try/catch ('уже существует' RU + EN). + + // v8.31 (hole #2): создаём начальные партиции для 9 партиционированных таблиц + // (deals, supplier_lead_costs + 7 audit-таблиц). Без этого первый INSERT + // упадёт с "no partition found for row". Cron partitions:create-months + // поддерживает их далее (текущий + ahead, default 2 месяца вперёд). + Artisan::call('partitions:create-months', ['--ahead' => 2]); } public function down(): void diff --git a/app/database/migrations/2026_05_22_000001_tenant_operations_log.php b/app/database/migrations/2026_05_22_000001_tenant_operations_log.php index c554c311..39b6bc0e 100644 --- a/app/database/migrations/2026_05_22_000001_tenant_operations_log.php +++ b/app/database/migrations/2026_05_22_000001_tenant_operations_log.php @@ -7,6 +7,14 @@ return new class extends Migration { public function up(): void { + // Idempotency guard: if schema.sql was loaded first, the table already exists + // (as a partitioned table from hole #2 migration). Skip creation in that case. + // The 2026_05_23_000002_partition_audit_tables migration will handle the partitioned + // form when it runs. + if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['tenant_operations_log']) !== null) { + return; + } + $sql = file_get_contents(base_path('../db/migrations/2026_05_22_001_tenant_operations_log.sql')); if ($sql === false) { throw new RuntimeException('Migration SQL file not found.'); diff --git a/app/resources/views/emails/audit_chain_breach_text.blade.php b/app/resources/views/emails/audit_chain_breach_text.blade.php index 476cc3ca..2046a858 100644 --- a/app/resources/views/emails/audit_chain_breach_text.blade.php +++ b/app/resources/views/emails/audit_chain_breach_text.blade.php @@ -1,12 +1,14 @@ Аудит hash-chain: РАЗРЫВ ЦЕПОЧКИ =================================== Таблица: {{ $tableName }} +Партиция: {{ $partitionName }} Первый сломанный id: {{ $firstBrokenId }} Несовпадений: {{ $mismatchCount }} Время: {{ $now }} (МСК) -ВНИМАНИЕ: разрыв hash-chain означает, что строки в таблице {{ $tableName }} -могли быть изменены или удалены в обход триггеров (прямой SQL под суперюзером). +ВНИМАНИЕ: разрыв hash-chain означает, что строки в партиции {{ $partitionName }} +(таблица {{ $tableName }}) могли быть изменены или удалены в обход триггеров +(прямой SQL под суперюзером). Необходимо срочно: 1. Проверить pg_audit log на предмет DDL/UPDATE/DELETE без триггеров. diff --git a/app/routes/console.php b/app/routes/console.php index eedb7e9b..752fa17a 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -54,6 +54,16 @@ Schedule::command('partitions:create-months') ->onSuccess(fn () => $hb->recordRunResult('partitions:create-months', true, null, null)) ->onFailure(fn () => $hb->recordRunResult('partitions:create-months', false, 'Command failed', null)); +// Hole #2 (23.05.2026): удаление устаревших месячных партиций согласно retention +// (system_settings: partition_retention_months_
). +// Запускается еженедельно в воскресенье в 03:00 МСК — вне пиковых часов, +// но раз в неделю достаточно (данные удаляются целыми месяцами). +Schedule::command('partitions:drop-expired') + ->weeklyOn(0, '03:00') + ->timezone('Europe/Moscow') + ->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null)) + ->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null)); + // Plan 3 Task 8: 5 Schedule entries для supplier-flow. // // NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет diff --git a/app/tests/Feature/Console/PartitionsDropExpiredTest.php b/app/tests/Feature/Console/PartitionsDropExpiredTest.php new file mode 100644 index 00000000..dcdc031d --- /dev/null +++ b/app/tests/Feature/Console/PartitionsDropExpiredTest.php @@ -0,0 +1,185 @@ +where('key', $key)->delete(); + + return; + } + + DB::table('system_settings')->upsert( + [ + 'key' => $key, + 'value' => (string) $months, + 'type' => 'int', + 'description' => "Test retention for {$table}", + 'updated_at' => now(), + ], + ['key'], + ['value', 'updated_at'], + ); +} + +// --------------------------------------------------------------------------- +// Helper: create a test partition for a given table and month. +// Returns partition name (e.g. auth_log_y2026_m02). +// --------------------------------------------------------------------------- +function createTestPartition(string $table, Carbon $monthStart): string +{ + /** @var MonthlyPartitionManager $mgr */ + $mgr = app(MonthlyPartitionManager::class); + $mgr->ensureMonth($table, $monthStart); + + return $mgr->partitionName($table, $monthStart); +} + +// --------------------------------------------------------------------------- +// Helper: check whether a partition physically exists in pg_class. +// Named with "Drop" prefix to avoid collision with MonthlyPartitionManagerTest. +// --------------------------------------------------------------------------- +function dropExpiredPartitionExists(string $partitionName): bool +{ + return DB::selectOne( + "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'", + [$partitionName], + ) !== null; +} + +// --------------------------------------------------------------------------- +// Shared teardown: reset Carbon fake time after each test. +// --------------------------------------------------------------------------- +afterEach(function () { + Carbon::setTestNow(null); +}); + +// =========================================================================== +// Tests +// =========================================================================== + +test('dry-run: partition is not dropped', function () { + Carbon::setTestNow('2026-05-15'); + + $oldMonth = Carbon::create(2026, 2, 1)->startOfMonth(); // 3 months ago + $partition = createTestPartition('auth_log', $oldMonth); + setRetention('auth_log', 1); // cutoff = 2026-04, so 2026-02 would normally drop + + expect(dropExpiredPartitionExists($partition))->toBeTrue('Partition must exist before command'); + + $this->artisan('partitions:drop-expired --dry-run')->assertSuccessful(); + + // Dry-run never physically drops + expect(dropExpiredPartitionExists($partition))->toBeTrue('Dry-run must NOT drop the partition'); +})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)'); + +test('drops partition older than retention boundary', function () { + Carbon::setTestNow('2026-05-15'); + + // retention=2: cutoff = 2026-03; 2026-02 is strictly older → drop + $oldMonth = Carbon::create(2026, 2, 1)->startOfMonth(); + $partition = createTestPartition('auth_log', $oldMonth); + setRetention('auth_log', 2); + + expect(dropExpiredPartitionExists($partition))->toBeTrue(); + + $this->artisan('partitions:drop-expired')->assertSuccessful(); + + expect(dropExpiredPartitionExists($partition))->toBeFalse('Partition beyond retention must be dropped'); +})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)'); + +test('does not drop partition at the retention boundary (inclusive keep)', function () { + Carbon::setTestNow('2026-05-15'); + + // retention=3: cutoff = 2026-02; 2026-02 is NOT strictly less than cutoff → keep + $boundaryMonth = Carbon::create(2026, 2, 1)->startOfMonth(); + $partition = createTestPartition('auth_log', $boundaryMonth); + setRetention('auth_log', 3); + + expect(dropExpiredPartitionExists($partition))->toBeTrue(); + + $this->artisan('partitions:drop-expired')->assertSuccessful(); + + expect(dropExpiredPartitionExists($partition))->toBeTrue('Partition at boundary must NOT be dropped'); +})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)'); + +test('skips table when retention is not configured', function () { + Carbon::setTestNow('2026-05-15'); + + setRetention('auth_log', null); // remove any retention setting + + $oldMonth = Carbon::create(2025, 1, 1)->startOfMonth(); + $partition = createTestPartition('auth_log', $oldMonth); + + expect(dropExpiredPartitionExists($partition))->toBeTrue(); + + $this->artisan('partitions:drop-expired')->assertSuccessful(); + + expect(dropExpiredPartitionExists($partition))->toBeTrue('No retention config → nothing dropped'); +})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)'); + +test('skips table when retention value is 0 (safety guard)', function () { + Carbon::setTestNow('2026-05-15'); + + $oldMonth = Carbon::create(2024, 1, 1)->startOfMonth(); + $partition = createTestPartition('auth_log', $oldMonth); + setRetention('auth_log', 0); + + expect(dropExpiredPartitionExists($partition))->toBeTrue(); + + $this->artisan('partitions:drop-expired')->assertSuccessful(); + + expect(dropExpiredPartitionExists($partition))->toBeTrue('retention=0 must be blocked — nothing dropped'); +})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)'); + +test('keeps recent partitions, drops only expired ones', function () { + Carbon::setTestNow('2026-05-15'); + + // retention=2 → cutoff = 2026-03 + // keep: 2026-05 (current), 2026-04 (1mo ago), 2026-03 (boundary) + // drop: 2026-02 (3mo ago), 2026-01 (4mo ago) + setRetention('auth_log', 2); + + $keep1 = createTestPartition('auth_log', Carbon::create(2026, 5, 1)); + $keep2 = createTestPartition('auth_log', Carbon::create(2026, 4, 1)); + $keep3 = createTestPartition('auth_log', Carbon::create(2026, 3, 1)); + $drop1 = createTestPartition('auth_log', Carbon::create(2026, 2, 1)); + $drop2 = createTestPartition('auth_log', Carbon::create(2026, 1, 1)); + + $this->artisan('partitions:drop-expired')->assertSuccessful(); + + expect(dropExpiredPartitionExists($keep1))->toBeTrue('2026-05 must be kept (current)'); + expect(dropExpiredPartitionExists($keep2))->toBeTrue('2026-04 must be kept (within retention)'); + expect(dropExpiredPartitionExists($keep3))->toBeTrue('2026-03 must be kept (at boundary)'); + expect(dropExpiredPartitionExists($drop1))->toBeFalse('2026-02 must be dropped'); + expect(dropExpiredPartitionExists($drop2))->toBeFalse('2026-01 must be dropped'); +})->skip(fn () => ! authLogIsPartitioned(), 'auth_log is not partitioned (migration not applied)'); diff --git a/app/tests/Feature/Console/VerifyAuditChainsTest.php b/app/tests/Feature/Console/VerifyAuditChainsTest.php index a9407808..834d9efd 100644 --- a/app/tests/Feature/Console/VerifyAuditChainsTest.php +++ b/app/tests/Feature/Console/VerifyAuditChainsTest.php @@ -4,6 +4,7 @@ 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; @@ -116,7 +117,12 @@ beforeEach(function () { test('clean auth_log chain verifies intact', function () { insertAuthLogRows(3); - $this->artisan('audit:verify-chains')->assertSuccessful(); + $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') @@ -325,6 +331,73 @@ test('incident dedup: same table breach does not create duplicate within 24h', f 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 // =========================================================================== diff --git a/app/tests/Feature/Import/MonthlyPartitionManagerTest.php b/app/tests/Feature/Import/MonthlyPartitionManagerTest.php index e536c944..5691bf07 100644 --- a/app/tests/Feature/Import/MonthlyPartitionManagerTest.php +++ b/app/tests/Feature/Import/MonthlyPartitionManagerTest.php @@ -17,6 +17,10 @@ function partitionExists(string $name): bool ) !== null; } +// --------------------------------------------------------------------------- +// Existing tests (deals — business table, received_at key) +// --------------------------------------------------------------------------- + test('ensureRange создаёт месячные партиции deals под диапазон', function (): void { $manager = app(MonthlyPartitionManager::class); @@ -27,9 +31,9 @@ test('ensureRange создаёт месячные партиции deals под ); expect($created)->toBeGreaterThanOrEqual(3) - ->and(partitionExists('deals_2024_02'))->toBeTrue() - ->and(partitionExists('deals_2024_03'))->toBeTrue() - ->and(partitionExists('deals_2024_04'))->toBeTrue(); + ->and(partitionExists('deals_y2024_m02'))->toBeTrue() + ->and(partitionExists('deals_y2024_m03'))->toBeTrue() + ->and(partitionExists('deals_y2024_m04'))->toBeTrue(); }); test('ensureRange идемпотентна — повторный вызов не падает', function (): void { @@ -44,3 +48,79 @@ test('ensureRange идемпотентна — повторный вызов н test('ensureRange отвергает неизвестную таблицу', function (): void { app(MonthlyPartitionManager::class)->ensureRange('orders', now(), now()); })->throws(InvalidArgumentException::class); + +// --------------------------------------------------------------------------- +// Hole #2 tests: audit tables (created_at key) +// --------------------------------------------------------------------------- + +test('ensureMonth создаёт партицию auth_log (created_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $month = Carbon::parse('2024-03-01'); + + $manager->ensureMonth('auth_log', $month); + + expect(partitionExists('auth_log_y2024_m03'))->toBeTrue(); +}); + +test('ensureMonth создаёт партицию activity_log (created_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('activity_log', Carbon::parse('2024-03-01')); + + expect(partitionExists('activity_log_y2024_m03'))->toBeTrue(); +}); + +test('ensureMonth создаёт партицию tenant_operations_log (created_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('tenant_operations_log', Carbon::parse('2024-03-01')); + + expect(partitionExists('tenant_operations_log_y2024_m03'))->toBeTrue(); +}); + +test('ensureMonth создаёт партицию webhook_log (received_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('webhook_log', Carbon::parse('2024-03-01')); + + expect(partitionExists('webhook_log_y2024_m03'))->toBeTrue(); +}); + +test('ensureMonth создаёт партицию balance_transactions (created_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('balance_transactions', Carbon::parse('2024-03-01')); + + expect(partitionExists('balance_transactions_y2024_m03'))->toBeTrue(); +}); + +test('ensureMonth создаёт партицию pd_processing_log (created_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('pd_processing_log', Carbon::parse('2024-03-01')); + + expect(partitionExists('pd_processing_log_y2024_m03'))->toBeTrue(); +}); + +test('ensureMonth создаёт партицию saas_admin_audit_log (created_at)', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('saas_admin_audit_log', Carbon::parse('2024-03-01')); + + expect(partitionExists('saas_admin_audit_log_y2024_m03'))->toBeTrue(); +}); + +test('partitionName возвращает правильный формат', function (): void { + $manager = app(MonthlyPartitionManager::class); + + expect($manager->partitionName('auth_log', Carbon::parse('2026-05-15'))) + ->toBe('auth_log_y2026_m05'); + + expect($manager->partitionName('deals', Carbon::parse('2024-01-01'))) + ->toBe('deals_y2024_m01'); +}); + +test('listPartitions возвращает созданные партиции', function (): void { + $manager = app(MonthlyPartitionManager::class); + $manager->ensureMonth('auth_log', Carbon::parse('2024-04-01')); + $manager->ensureMonth('auth_log', Carbon::parse('2024-05-01')); + + $partitions = $manager->listPartitions('auth_log'); + + expect($partitions)->toContain('auth_log_y2024_m04') + ->toContain('auth_log_y2024_m05'); +}); diff --git a/app/tests/Feature/PartitionsCreateMonthsTest.php b/app/tests/Feature/PartitionsCreateMonthsTest.php index c8c389e7..c18cf953 100644 --- a/app/tests/Feature/PartitionsCreateMonthsTest.php +++ b/app/tests/Feature/PartitionsCreateMonthsTest.php @@ -17,7 +17,7 @@ beforeEach(function () { $this->partitionsBefore = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->pluck('relname')->all(); }); @@ -25,14 +25,15 @@ afterEach(function () { $partitionsAfter = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->pluck('relname')->all(); // DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от // webhook_dedup_keys → deals (parent partitioned table), и // последующий ON DELETE CASCADE тест валится. + // Восстанавливаем имя parent-таблицы из имени партиции `_yYYYY_mMM` foreach (array_diff($partitionsAfter, $this->partitionsBefore) as $partition) { - $parent = str_starts_with($partition, 'deals_') ? 'deals' : 'supplier_lead_costs'; + $parent = preg_replace('/_y?\d{4}_m?\d{2}$/', '', $partition); DB::statement("ALTER TABLE {$parent} DETACH PARTITION {$partition}"); DB::statement("DROP TABLE IF EXISTS {$partition}"); } @@ -45,8 +46,8 @@ test('создаёт партиции на N месяцев вперёд для // Должны быть партиции до текущий+8 месяцев включительно. $futureMonth = now()->startOfMonth()->addMonths(8); - $expectedDealName = 'deals_'.$futureMonth->format('Y_m'); - $expectedCostName = 'supplier_lead_costs_'.$futureMonth->format('Y_m'); + $expectedDealName = 'deals_'.'y'.$futureMonth->format('Y').'_m'.$futureMonth->format('m'); + $expectedCostName = 'supplier_lead_costs_'.'y'.$futureMonth->format('Y').'_m'.$futureMonth->format('m'); $row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$expectedDealName]); expect($row)->not->toBeNull(); @@ -60,7 +61,7 @@ test('идемпотентность: повторный запуск не па $afterFirst = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->count(); // Повторный запуск — должен только skip'ать. @@ -70,21 +71,21 @@ test('идемпотентность: повторный запуск не па $afterSecond = collect(DB::select(" SELECT relname FROM pg_class WHERE relkind = 'r' - AND relname ~ '^(deals|supplier_lead_costs)_[0-9]{4}_[0-9]{2}$' + AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$' "))->count(); expect($afterSecond)->toBe($afterFirst); - // Output второго запуска должен говорить «skipped» по всем 12 партициям (6 мес × 2 табл). + // Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции. $output = Artisan::output(); - expect($output)->toContain('0 created, 12 skipped'); + expect($output)->toContain('0 created, 54 skipped'); }); test('--ahead=0 создаёт только текущий месяц', function () { Artisan::call('partitions:create-months', ['--ahead' => 0]); $currentMonth = now()->startOfMonth(); - $name = 'deals_'.$currentMonth->format('Y_m'); + $name = 'deals_y'.$currentMonth->format('Y').'_m'.$currentMonth->format('m'); $row = DB::selectOne("SELECT 1 AS x FROM pg_class WHERE relname = ? AND relkind = 'r'", [$name]); expect($row)->not->toBeNull(); diff --git a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php index 5e02c820..6e2ad75c 100644 --- a/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php +++ b/app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php @@ -73,15 +73,18 @@ it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS $schema = file_get_contents($schemaPath); expect($schema)->not->toBeFalse(); - // 65 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF). + // v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6). + // v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2. + // + // 67 base tables = все CREATE TABLE минус PARTITION OF. $createTables = preg_match_all('/^CREATE TABLE\b/m', $schema); $partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema); $baseTables = $createTables - $partitionOf; - expect($baseTables)->toBe(65); + expect($baseTables)->toBe(67); $createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema); - expect($createIndexes)->toBe(123); // v8.26: +2 supplier_projects_platform_key_subject_unique, idx_psl_* + expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning $createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema); - expect($createPolicies)->toBe(40); + expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах }); diff --git a/cspell-words.txt b/cspell-words.txt index 84698d26..54260597 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1706,3 +1706,7 @@ FNS дебаг валидируется рендериться + +# Hole #2 partitioning (23.05.2026) +партиционировать +дёшева diff --git a/db/CHANGELOG_schema.md b/db/CHANGELOG_schema.md index 2ccaa078..92ce647e 100644 --- a/db/CHANGELOG_schema.md +++ b/db/CHANGELOG_schema.md @@ -1,11 +1,55 @@ # CHANGELOG schema.sql — Лидерра -**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать семь записей в обратном хронологическом порядке (v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. +**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать восемь записей в обратном хронологическом порядке (v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog. -**Файл схемы:** `schema.sql` (текущая версия — v8.30, консолидированная — разворачивает БД с нуля). +**Файл схемы:** `schema.sql` (текущая версия — v8.31, консолидированная — разворачивает БД с нуля). **История записей:** +## v8.31 — 2026-05-23 — партиционирование 7 audit-таблиц (hole #2) + +Закрывает дыру #2 аудита журналирования: все 7 audit-таблиц переведены на +RANGE-партиционирование помесячно. Управление партициями — `MonthlyPartitionManager` +(extended до 9 таблиц) + cron `partitions:create-months` + cron `partitions:drop-expired` (новый). + +**Таблицы, переведённые на партиционирование:** + +| Таблица | Partition key | PK до | PK после | +|---|---|---|---| +| `auth_log` | `created_at` | `(id)` | `(id, created_at)` | +| `activity_log` | `created_at` | `(id)` | `(id, created_at)` | +| `tenant_operations_log` | `created_at` | `(id)` | `(id, created_at)` | +| `webhook_log` | `received_at` | `(id)` | `(id, received_at)` | +| `balance_transactions` | `created_at` | `(id)` | `(id, created_at)` | +| `pd_processing_log` | `created_at` | `(id)` | `(id, created_at)` | +| `saas_admin_audit_log` | `created_at` | `(id)` | `(id, created_at)` | + +**FK удалены (W1):** + +- `failed_webhook_jobs.webhook_log_id` — FK снят, колонка сохранена как `BIGINT` (без ссылочной целостности; composite PK партиционированной таблицы несовместим с одиночным FK-столбцом) +- `rejected_deals_log.webhook_log_id` — аналогично + +**Partition naming format:** `
_y_m` (пример: `auth_log_y2026_m05`). +Применён и к ранее существующим таблицам `deals` / `supplier_lead_costs` — partition children +в schema.sql переименованы. + +**tenant_operations_log:** RLS и триггеры перенесены из inline-определения таблицы в +централизованные секции (единообразно с остальными таблицами). Счётчик триггеров: 5 → 6 пар. + +**Retention defaults (в system_settings через migration):** + +- `auth_log_retention_months = 24` +- `activity_log_retention_months = 36` +- `tenant_operations_log_retention_months = 24` +- `webhook_log_retention_months = 3` +- `balance_transactions_retention_months = 84` +- `pd_processing_log_retention_months = 36` +- `saas_admin_audit_log_retention_months = 84` + +Миграция: `2026_05_23_000002_partition_audit_tables.php`. + +**Метрики после:** 74 таблицы (65 regular + 9 partitioned parents) / 125 индексов / 41 RLS / 6 пар audit-триггеров / 5 user-функций. + ## v8.30 — 2026-05-23 — scheduler_heartbeats (hole #6 cron heartbeat) +1 таблица `scheduler_heartbeats` — SaaS-уровневый пульс всех cron-задач (дыра #6 аудита diff --git a/db/schema.sql b/db/schema.sql index 17883088..f52cf67b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,10 +1,11 @@ -- ============================================================================= -- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра») --- Версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) +-- Версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings) +-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6) -- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns) -- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure) -- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete())) --- Метрики: 67 базовые таблицы (65 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 125 индексов / 41 RLS-политика / 5 функций / 15 триггеров +-- Метрики: 74 базовые таблицы (65 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 41 RLS-политика / 5 функций / 15 триггеров -- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов) -- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2)) -- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов) @@ -1450,8 +1451,12 @@ CREATE INDEX idx_outbound_deliveries_created ON outbound_webhook_deliv -- РАСШИРЕНИЕ v8.1: добавлены actor_type и saas_admin_user_id для объединения -- логов входов клиентских пользователей и админов SaaS в одной таблице. -- ----------------------------------------------------------------------------- +-- v8.31: партиционирована помесячно по created_at (hole #2). +-- PK изменён с (id) на (id, created_at) для совместимости с RANGE-партиционированием PG 16. +-- Стартовые партиции создаются миграцией 2026_05_23_000002_partition_audit_tables +-- и далее cron'ом partitions:create-months. CREATE TABLE auth_log ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, actor_type VARCHAR(20) NOT NULL DEFAULT 'tenant_user' CHECK (actor_type IN ('tenant_user','saas_admin')), tenant_id BIGINT REFERENCES tenants(id), -- NULL для админов SaaS @@ -1469,15 +1474,16 @@ CREATE TABLE auth_log ( -- среднюю строку или вставит фейковую — пересчёт цепочки покажет -- разрыв. UPDATE/DELETE заблокированы триггером BEFORE. log_hash BYTEA, -- NULL → fill via trigger BEFORE INSERT - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) -- Целостность: actor должен быть ровно одного типа CONSTRAINT chk_auth_log_actor CHECK ( (actor_type = 'tenant_user' AND user_id IS NOT NULL AND saas_admin_user_id IS NULL) OR (actor_type = 'saas_admin' AND saas_admin_user_id IS NOT NULL AND user_id IS NULL) OR (actor_type = 'tenant_user' AND user_id IS NULL AND saas_admin_user_id IS NULL AND email IS NOT NULL) -- Третий вариант: попытка входа с email, но user_id неизвестен (login_failed для неcуществующего email) - ) -); + ), + PRIMARY KEY (id, created_at) -- v8.31: composite PK (partition key required) +) PARTITION BY RANGE (created_at); CREATE INDEX idx_auth_log_tenant_user ON auth_log(tenant_id, user_id, created_at DESC); CREATE INDEX idx_auth_log_admin ON auth_log(saas_admin_user_id, created_at DESC) WHERE saas_admin_user_id IS NOT NULL; @@ -1687,12 +1693,13 @@ CREATE INDEX ON deals (tenant_id, status) WHERE deleted_at IS NULL; -- Стартовые партиции (создаются cron-ом раз в сутки на 2 месяца вперёд). -- Здесь — заготовка на ближайшие 6 месяцев от текущей даты схемы (май 2026). -CREATE TABLE deals_2026_05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); -CREATE TABLE deals_2026_06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); -CREATE TABLE deals_2026_07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); -CREATE TABLE deals_2026_08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); -CREATE TABLE deals_2026_09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01'); -CREATE TABLE deals_2026_10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); +-- v8.31: переименованы в формат
_y_m (совпадает с MonthlyPartitionManager::partitionName()). +CREATE TABLE deals_y2026_m05 PARTITION OF deals FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +CREATE TABLE deals_y2026_m06 PARTITION OF deals FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); +CREATE TABLE deals_y2026_m07 PARTITION OF deals FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); +CREATE TABLE deals_y2026_m08 PARTITION OF deals FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); +CREATE TABLE deals_y2026_m09 PARTITION OF deals FOR VALUES FROM ('2026-09-01') TO ('2026-10-01'); +CREATE TABLE deals_y2026_m10 PARTITION OF deals FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); -- ----------------------------------------------------------------------------- @@ -1766,8 +1773,9 @@ CREATE INDEX idx_deal_tag_pivot_tag ON deal_tag_pivot(tag_id); -- activity_log — журнал действий по сделкам (раздел 14.4) -- РЕТЕНШН: 3 года активно, далее в S3 Glacier -- ----------------------------------------------------------------------------- +-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at). CREATE TABLE activity_log ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT REFERENCES users(id), -- NULL для системных событий deal_id BIGINT NOT NULL, -- БЕЗ FK (deals партиционирована) @@ -1778,8 +1786,9 @@ CREATE TABLE activity_log ( ip_address INET, user_agent TEXT, log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log) - created_at TIMESTAMPTZ DEFAULT NOW() -); + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) + PRIMARY KEY (id, created_at) -- v8.31: composite PK +) PARTITION BY RANGE (created_at); CREATE INDEX idx_activity_tenant_deal_created ON activity_log(tenant_id, deal_id, created_at DESC); CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id, created_at DESC) WHERE user_id IS NOT NULL; @@ -1788,8 +1797,10 @@ CREATE INDEX idx_activity_tenant_user_created ON activity_log(tenant_id, user_id -- tenant_operations_log — журнал тенант-уровневых операций вне сделок -- (проекты, API-ключи, исходящий webhook URL, и т.п.). Защищён hash-chain. -- ============================================================================= +-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at). +-- RLS и триггеры перенесены в секцию RLS/Triggers (единообразно с другими партиционированными). CREATE TABLE tenant_operations_log ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, user_id BIGINT REFERENCES users(id), -- NULL для системных entity_type VARCHAR(50) NOT NULL, -- 'project', 'api_key', 'webhook_settings' @@ -1800,8 +1811,9 @@ CREATE TABLE tenant_operations_log ( ip_address INET, user_agent TEXT, log_hash BYTEA, -- hash chain - created_at TIMESTAMPTZ DEFAULT NOW() -); + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) + PRIMARY KEY (id, created_at) -- v8.31: composite PK +) PARTITION BY RANGE (created_at); CREATE INDEX idx_tenant_ops_tenant_created ON tenant_operations_log(tenant_id, created_at DESC); @@ -1809,17 +1821,6 @@ CREATE INDEX idx_tenant_ops_entity ON tenant_operations_log(tenant_id, entity_type, entity_id, created_at DESC) WHERE entity_id IS NOT NULL; -ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation ON tenant_operations_log - USING (tenant_id = current_setting('app.current_tenant_id')::bigint); - -CREATE TRIGGER trg_audit_chain_hash_tenant_ops - BEFORE INSERT ON tenant_operations_log - FOR EACH ROW EXECUTE FUNCTION audit_chain_hash(); -CREATE TRIGGER trg_audit_block_mut_tenant_ops - BEFORE UPDATE OR DELETE ON tenant_operations_log - FOR EACH ROW EXECUTE FUNCTION audit_block_mutation(); - -- ----------------------------------------------------------------------------- -- reminders — напоминания по сделкам (раздел 17.5) -- v8.3: расширено по итогам партии 12.2 аудита. @@ -1924,11 +1925,14 @@ COMMENT ON TABLE in_app_notifications IS -- webhook_log — лог принятых webhook (раздел 5.7) -- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней) -- ----------------------------------------------------------------------------- +-- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at). +-- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK +-- партиционированной таблицы с единичным FK-столбцом). CREATE TABLE webhook_log ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook) raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации - received_at TIMESTAMPTZ DEFAULT NOW(), + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) processed_at TIMESTAMPTZ, deal_id BIGINT, -- БЕЗ FK (deals партиционирована) error TEXT, @@ -1937,8 +1941,9 @@ CREATE TABLE webhook_log ( status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited' lead_id BIGINT, -- supplier_leads.id при статусе 'received' ip_address INET, -- клиентский IP - created_at TIMESTAMPTZ DEFAULT NOW() -); + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (id, received_at) -- v8.31: composite PK +) PARTITION BY RANGE (received_at); CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC); CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC); @@ -1951,7 +1956,7 @@ CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC); CREATE TABLE failed_webhook_jobs ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, - webhook_log_id BIGINT REFERENCES webhook_log(id), + webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK) raw_payload JSONB NOT NULL, exception TEXT NOT NULL, retry_count INT DEFAULT 3, @@ -1973,7 +1978,7 @@ CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); CREATE TABLE rejected_deals_log ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, - webhook_log_id BIGINT REFERENCES webhook_log(id), + webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK) reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ... payload JSONB, created_at TIMESTAMPTZ DEFAULT NOW() @@ -2320,8 +2325,9 @@ CREATE INDEX idx_refund_requests_chargeback ON refund_requests(tenant_id, reque -- ----------------------------------------------------------------------------- -- balance_transactions — внутренний лид-биллинг (раздел 7.3, 21) -- ----------------------------------------------------------------------------- +-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at). CREATE TABLE balance_transactions ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, type VARCHAR(50) NOT NULL CHECK (type IN ('trial_bonus','topup','lead_charge','refund', @@ -2340,8 +2346,9 @@ CREATE TABLE balance_transactions ( -- Для manual_adjustment — кто из админов SaaS сделал admin_user_id BIGINT REFERENCES saas_admin_users(id), log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log) - created_at TIMESTAMPTZ DEFAULT NOW() -); + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) + PRIMARY KEY (id, created_at) -- v8.31: composite PK +) PARTITION BY RANGE (created_at); CREATE INDEX idx_balance_tenant_created ON balance_transactions(tenant_id, created_at DESC); CREATE INDEX idx_balance_tenant_type ON balance_transactions(tenant_id, type); @@ -2411,12 +2418,13 @@ CREATE INDEX ON supplier_lead_costs (supplier_invoice_id) WHERE supplier_invoice CREATE INDEX ON supplier_lead_costs (supplier_id, received_at DESC); -- v8.2: аналитика "лиды по поставщикам" -- Партиции синхронно с deals (создаются cron на 2 месяца вперёд) -CREATE TABLE supplier_lead_costs_2026_05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); -CREATE TABLE supplier_lead_costs_2026_06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); -CREATE TABLE supplier_lead_costs_2026_07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); -CREATE TABLE supplier_lead_costs_2026_08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); -CREATE TABLE supplier_lead_costs_2026_09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01'); -CREATE TABLE supplier_lead_costs_2026_10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); +-- v8.31: переименованы в формат
_y_m. +CREATE TABLE supplier_lead_costs_y2026_m05 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-05-01') TO ('2026-06-01'); +CREATE TABLE supplier_lead_costs_y2026_m06 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-06-01') TO ('2026-07-01'); +CREATE TABLE supplier_lead_costs_y2026_m07 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-07-01') TO ('2026-08-01'); +CREATE TABLE supplier_lead_costs_y2026_m08 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-08-01') TO ('2026-09-01'); +CREATE TABLE supplier_lead_costs_y2026_m09 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-09-01') TO ('2026-10-01'); +CREATE TABLE supplier_lead_costs_y2026_m10 PARTITION OF supplier_lead_costs FOR VALUES FROM ('2026-10-01') TO ('2026-11-01'); -- ----------------------------------------------------------------------------- @@ -2490,8 +2498,9 @@ CREATE INDEX idx_consents_tenant ON tenant_consents(tenant_id, consent_type); -- pd_processing_log — журнал обработки ПДн (раздел 22.9.3) -- РАСШИРЕНИЕ v8.1: разделение actor_user_id на два поля с FK -- ----------------------------------------------------------------------------- +-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at). CREATE TABLE pd_processing_log ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, tenant_id BIGINT REFERENCES tenants(id), subject_type VARCHAR(50), -- 'user', 'lead' subject_id BIGINT, @@ -2502,13 +2511,14 @@ CREATE TABLE pd_processing_log ( actor_admin_user_id BIGINT REFERENCES saas_admin_users(id), ip_address INET, log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log) - created_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) CONSTRAINT chk_pd_actor CHECK ( (actor_tenant_user_id IS NOT NULL AND actor_admin_user_id IS NULL) OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NOT NULL) OR (actor_tenant_user_id IS NULL AND actor_admin_user_id IS NULL) -- системное действие - ) -); + ), + PRIMARY KEY (id, created_at) -- v8.31: composite PK +) PARTITION BY RANGE (created_at); CREATE INDEX idx_pd_log_tenant ON pd_processing_log(tenant_id, created_at DESC); CREATE INDEX idx_pd_log_admin_actor ON pd_processing_log(actor_admin_user_id, created_at DESC) WHERE actor_admin_user_id IS NOT NULL; @@ -2663,8 +2673,10 @@ COMMENT ON TABLE scheduler_heartbeats IS -- saas_admin_users уже создана выше (нужна была для FK от других таблиц) -- ============================================================================= +-- v8.31: партиционирована помесячно по created_at (hole #2). PK → (id, created_at). +-- Без RLS: доступна только crm_admin_user (BYPASSRLS). CREATE TABLE saas_admin_audit_log ( - id BIGSERIAL PRIMARY KEY, + id BIGSERIAL, admin_user_id BIGINT NOT NULL REFERENCES saas_admin_users(id), action VARCHAR(100) NOT NULL, -- 'tenant.suspend', 'refund.approve', 'system_settings.update', ... target_type VARCHAR(50), -- 'tenant', 'saas_transaction', 'system_setting', ... @@ -2680,8 +2692,9 @@ CREATE TABLE saas_admin_audit_log ( approved_by BIGINT REFERENCES saas_admin_users(id), approved_at TIMESTAMPTZ, log_hash BYTEA, -- v8.5 (OPEN-И-15): hash chain (см. auth_log) - created_at TIMESTAMPTZ DEFAULT NOW() -); + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key) + PRIMARY KEY (id, created_at) -- v8.31: composite PK +) PARTITION BY RANGE (created_at); CREATE INDEX idx_admin_audit_admin ON saas_admin_audit_log(admin_user_id, created_at DESC); CREATE INDEX idx_admin_audit_tenant ON saas_admin_audit_log(target_tenant_id, created_at DESC) WHERE target_tenant_id IS NOT NULL; @@ -2787,7 +2800,15 @@ INSERT INTO system_settings (key, value, type, description) VALUES ('projects_purge_deleted_cron', '0 4 * * *', 'string', 'Расписание cron projects:purge-deleted (по умолчанию 04:00 МСК ежедневно)'), -- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth. ('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'), - ('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'); + ('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'), + -- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК). + ('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'), + ('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'), + ('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'), + ('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'), + ('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'), + ('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'), + ('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)'); -- 4 стартовых тарифа-заглушки (Биз-1: вариант Б). @@ -2855,6 +2876,7 @@ ALTER TABLE deal_tags ENABLE ROW LEVEL SECURITY; ALTER TABLE import_log ENABLE ROW LEVEL SECURITY; ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY; ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline) ALTER TABLE reminders ENABLE ROW LEVEL SECURITY; ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY; ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY; @@ -2896,6 +2918,7 @@ CREATE POLICY tenant_isolation ON deal_tags USING (tenant_id = cur CREATE POLICY tenant_isolation ON import_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); +CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint); @@ -3093,7 +3116,8 @@ COMMENT ON FUNCTION audit_block_mutation() IS 'v8.5 (OPEN-И-15): запрещает UPDATE/DELETE на audit-таблицах. ' 'Совместно с REVOKE на роли — два слоя защиты от tampering.'; --- 5 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE) +-- 6 пар триггеров: hash-fill (BEFORE INSERT) + block-mutation (BEFORE UPDATE/DELETE) +-- v8.31: tenant_operations_log перенесён из inline-определения таблицы; итого 6 пар. CREATE TRIGGER trg_audit_chain_hash_auth_log BEFORE INSERT ON auth_log @@ -3109,6 +3133,13 @@ CREATE TRIGGER trg_audit_block_mut_activity_log BEFORE UPDATE OR DELETE ON activity_log FOR EACH ROW EXECUTE FUNCTION audit_block_mutation(); +CREATE TRIGGER trg_audit_chain_hash_tenant_ops + BEFORE INSERT ON tenant_operations_log + FOR EACH ROW EXECUTE FUNCTION audit_chain_hash(); +CREATE TRIGGER trg_audit_block_mut_tenant_ops + BEFORE UPDATE OR DELETE ON tenant_operations_log + FOR EACH ROW EXECUTE FUNCTION audit_block_mutation(); + CREATE TRIGGER trg_audit_chain_hash_pd_log BEFORE INSERT ON pd_processing_log FOR EACH ROW EXECUTE FUNCTION audit_chain_hash(); diff --git a/docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md b/docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md new file mode 100644 index 00000000..d9c1e827 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md @@ -0,0 +1,199 @@ +# Дыра #2 — план реализации partitioning 7 audit-таблиц + +**Spec:** `2026-05-23-hole-2-audit-partitioning-design.md` v1.0 +**Решения заказчика (23.05 вечер):** + +- Scope: все 7 таблиц +- FK: W1 (удалить FK на webhook_log от 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 (совместимо с hole #1 fix) + +--- + +## Phase A — Разработка (dev) + +### A.1. Расширить whitelist + +**Файл:** `app/app/Services/MonthlyPartitionManager.php` +**Изменение:** добавить 7 строк в `PARTITIONED_TABLES`. Map имя→partition-key: + +```php +public const PARTITIONED_TABLES = [ + 'deals' => 'received_at', + 'supplier_lead_costs' => 'received_at', + 'auth_log' => 'created_at', + 'activity_log' => 'created_at', + 'tenant_operations_log' => 'created_at', + 'webhook_log' => 'received_at', // в этой таблице ключ называется received_at + 'balance_transactions' => 'created_at', + 'pd_processing_log' => 'created_at', + 'saas_admin_audit_log' => 'created_at', +]; +``` + +Refactor `ensureMonth()` чтобы использовал key из map (сейчас hardcoded на `received_at`). + +**Test:** `MonthlyPartitionManagerTest` — добавить positive case на каждую новую таблицу + проверить что old test (throw на 'orders') продолжает работать. + +### A.2. Миграция rewrite + +**Файл:** `app/database/migrations/2026_05_23_000002_partition_audit_tables.php` +**Логика для каждой таблицы** (в одной транзакции BEGIN/COMMIT): + +1. `ALTER TABLE auth_log RENAME TO auth_log_old` +2. `CREATE TABLE auth_log (...same columns...) PARTITION BY RANGE (created_at)` с PK=`(id, created_at)` +3. Создать 6 партиций (3 прошлых + текущий + 2 будущих) — диапазоны `[Y_m_01, Y_m+1_01)` +4. Восстановить все индексы (как локальные через `CREATE INDEX ... ON auth_log (...)` — автонаследуются партиции) +5. Восстановить RLS-политики +6. Восстановить триггеры (особенно `audit_chain_hash` через `BEFORE INSERT`) +7. `INSERT INTO auth_log SELECT * FROM auth_log_old` +8. `DROP TABLE auth_log_old CASCADE` + +**FK удаление (только для webhook_log):** + +- `ALTER TABLE failed_webhook_jobs DROP CONSTRAINT failed_webhook_jobs_webhook_log_id_fkey` +- `ALTER TABLE rejected_deals_log DROP CONSTRAINT rejected_deals_log_webhook_log_id_fkey` +- Имена FK выяснить через `\d` или `pg_constraint` + +**ВАЖНО:** миграция через `DB::unprepared()` (raw SQL) — Laravel migrate уже паттерн на проде через postgres-role (см. hole #6 deploy lessons). + +### A.3. Обновить `db/schema.sql` + +- Заменить 7 объявлений `CREATE TABLE` на партиционированные версии +- PK `(id)` → `(id, created_at)` (или `received_at`) +- Bump schema header: v8.30 → v8.31 +- Запись в `db/CHANGELOG_schema.md` + +### A.4. Adapt `VerifyAuditChains` (per-partition scan) + +**Файл:** `app/app/Console/Commands/VerifyAuditChains.php` +**Изменение:** для каждой из 6 hash-chain таблиц (`auth_log`, `activity_log`, `tenant_operations_log`, `balance_transactions`, `pd_processing_log`, `saas_admin_audit_log`): + +- Получить список партиций через `pg_inherits` JOIN `pg_class` +- Для каждой партиции — проверить hash-chain отдельно +- Несоответствие в любой партиции → инцидент с указанием partition_name в summary + +**Test:** обновить `VerifyAuditChainsTest` — добавить кейс «разрыв в одной партиции, остальные intact → 1 инцидент с partition_name». + +### A.5. Новая команда `partitions:drop-expired` + +**Файл:** `app/app/Console/Commands/PartitionsDropExpired.php` +**Логика:** + +- Получить retention настройки из `system_settings` (defaults в коде, override через DB): + - `auth_log_retention_months`, `activity_log_retention_months`, etc. (7 ключей) +- Для каждой таблицы из `PARTITIONED_TABLES`: + - Найти партиции старше `NOW() - INTERVAL '{retention} months'` + - `DROP TABLE IF EXISTS {partition_name}` (атомарно) + - Лог: сколько дропнуто, какой freed space +- Если retention=0 (или не задан) — НЕ дропать (защита от случайного `0`) + +**Cron в `routes/console.php`:** Sundays 03:00 МСК + heartbeat-обёртка (как hole #6 паттерн). + +**Mailable:** опционально — `PartitionsDroppedReport` ежемесячно? Решим в коде — для МИНИМУМА просто info-лог. + +**Test:** `PartitionsDropExpiredTest`: + +- настройка retention=2 → дропает партиции старше 2 мес +- защита от 0/null → no-op +- идемпотентность (повторный run — 0 дропов) + +### A.6. seed retention defaults в system_settings + +**Файл:** `db/schema.sql` (раздел `system_settings` seed) + миграция inserts: + +```sql +INSERT INTO system_settings (key, value, description) VALUES + ('auth_log_retention_months', '24', 'Retention auth_log в месяцах (hole #2)'), + ...7 строк... +ON CONFLICT (key) DO NOTHING; +``` + +### A.7. Регрессия + +- `php artisan test` — все green (особенно: Auth*, Pd*, BalanceTransactions*, ImpersonationAudit*, PartitionsCreateMonths*, VerifyAuditChains*) +- `pint --test` +- `cspell` + `markdownlint` + +### A.8. Локальный smoke + +- `php artisan migrate:fresh --env=testing` — применит миграцию, схема корректна +- `php artisan partitions:create-months` — создаст партиции для всех 9 таблиц (2 старые + 7 новые) +- `php artisan partitions:drop-expired` — no-op (нет старых партиций) +- `php artisan audit:verify-chains` — должен пройти per-partition (empty chains = OK) + +--- + +## Phase B — Прод-выкатка (с явным approve каждого критического шага) + +### B.1. Pre-flight + +- `git fetch && git log HEAD..origin/main --oneline` — pre-flight sync +- Push всех A-коммитов на origin/main + +### B.2. Backup БД + +```bash +ssh ubuntu@111.88.246.137 +TS=$(date +%Y%m%d-%H%M%S) +sudo -u postgres pg_dump -Fc liderra > /home/ubuntu/deploy-backups/pre-partitioning-$TS.dump +# Verify +ls -lh /home/ubuntu/deploy-backups/pre-partitioning-$TS.dump +sudo -u postgres pg_restore --list /home/ubuntu/deploy-backups/pre-partitioning-$TS.dump | wc -l +``` + +### B.3. Pre-migration snapshot + +```sql +SELECT 'auth_log', COUNT(*) FROM auth_log +UNION ALL SELECT 'activity_log', COUNT(*) FROM activity_log +... 7 таблиц ...; +``` + +### B.4. Deploy code + +scp нескольких файлов: `MonthlyPartitionManager.php`, `VerifyAuditChains.php`, `PartitionsDropExpired.php`, `routes/console.php`. Install, optimize. + +### B.5. Apply migration + +Через `sudo -u postgres psql -d liderra -f /tmp/migration.sql` (НЕ через artisan migrate — права crm_app_user не хватит на DDL, повторяет hole #6 lesson). После — INSERT в `migrations` для учёта Laravel. + +### B.6. Post-migration verify + +- row counts соответствуют B.3 snapshot +- `\d+ auth_log` показывает PARTITION BY и 6 партиций +- `audit:verify-chains` rc=0 без инцидентов + +### B.7. Smoke 1 час + +Watch incidents_log, scheduler_heartbeats, queue activity. + +--- + +## Phase C — Documentation + +- ПИЛОТ.md §6 п.11 — добавить под-пункт #2 +- ЭТАЛОН.md — schema v8.31, metrics обновить +- `memory/project_7holes_audit_followup` — закрыть #2 +- `docs/superpowers/plans/2026-05-23-7-holes-overview.md` — tracker обновить (все 7 ✅) + +--- + +## Tracker + +- [ ] A.1. Whitelist + map в `MonthlyPartitionManager` +- [ ] A.2. Миграция rewrite (7 таблиц + FK drop) +- [ ] A.3. Update `db/schema.sql` + CHANGELOG +- [ ] A.4. Adapt `VerifyAuditChains` per-partition +- [ ] A.5. `PartitionsDropExpired` команда + cron +- [ ] A.6. Seed retention defaults +- [ ] A.7. Regression +- [ ] A.8. Локальный smoke +- [ ] B.1. Pre-flight +- [ ] B.2. Backup +- [ ] B.3. Snapshot +- [ ] B.4. Deploy code +- [ ] B.5. Apply migration +- [ ] B.6. Verify +- [ ] B.7. Smoke 1h +- [ ] C.1-C.4. Docs diff --git a/docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md b/docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md new file mode 100644 index 00000000..d6fd250b --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md @@ -0,0 +1,165 @@ +# Дыра #2 — партиционирование 7 audit-таблиц (design) + +**Версия:** v1.0 от 23.05.2026 (вечер) +**Триггер:** последняя из 7 дыр аудита журналирования. Заказчик: «делай дальше». +**План реализации:** `2026-05-23-hole-2-audit-partitioning-plan.md` (создаётся после approve этого spec). + +--- + +## 1. Контекст + +Из аудита журналирования: 7 audit-таблиц **растут вечно** на проде без механизма retention. Цель — partitioning **по месяцам** чтобы можно было `DROP` старых партиций (или `ATTACH` в архив). + +### 1.1. Текущее состояние (разведка 23.05 вечер) + +**Все 7 таблиц — обычные heap, БЕЗ partition** (memory была неверна): + +| # | Table | partition | hash-chain | RLS | size прод | rows прод | +|---|---|---|---|---|---|---| +| 1 | `auth_log` | нет | ✅ | ✅ tenant_user (saas-admin исключён) | 112 kB | 32 | +| 2 | `activity_log` | нет | ✅ | ✅ tenant_deal_created | 200 kB | 413 | +| 3 | `tenant_operations_log` | нет | ✅ (inline) | ✅ | 104 kB | 9 | +| 4 | `webhook_log` | нет | ❌ нет | ✅ | 32 kB | 0 | +| 5 | `balance_transactions` | нет | ✅ | ✅ | 128 kB | 275 | +| 6 | `pd_processing_log` | нет | ✅ | ✅ | 56 kB | 2 | +| 7 | `saas_admin_audit_log` | нет | ✅ | ❌ saas-only | 48 kB | 0 | + +**ИТОГО на проде: ~712 kB / 731 row.** Данных микро — миграция мгновенная. + +### 1.2. Существующий tooling + +- `App\Services\MonthlyPartitionManager::PARTITIONED_TABLES` — hardcoded whitelist на 2 таблицы (`deals`, `supplier_lead_costs`). +- `App\Console\Commands\PartitionsCreateMonths` — daily cron, `--ahead=2` месяца вперёд. Идемпотентно. +- `pg_partman` НЕ используется (был установлен на проде, но решено через app-cron). +- На dev накоплено 102 partition children для `deals`/`supplier_lead_costs`. + +### 1.3. Сложности + +**S1. PRIMARY KEY.** PostgreSQL требует partition-key в PK. Сейчас PK=`(id)`. После — PK=`(id, created_at)` (или `received_at` для `webhook_log`). + +**S2. FK на webhook_log.** Две таблицы ссылаются на `webhook_log.id`: + +- `failed_webhook_jobs.webhook_log_id` (схема L1954) — 0 строк +- `rejected_deals_log.webhook_log_id` (схема L1976) — 0 строк + +В PG 16 FK на partitioned-таблицу с композитным PK невозможен **по одной колонке**. Варианты: + +- (W1) **Удалить FK**, поддерживать целостность приложением (просто, минимально, 0 строк сейчас). +- (W2) Добавить `received_at`-колонку в `failed_webhook_jobs`/`rejected_deals_log` + композитный FK на `(webhook_log_id, received_at)`. Сложнее, но FK сохраняется. +- (W3) Не партиционировать `webhook_log` (нет hash-chain → нет аудиторной критичности; retention 90 дней через cleanup-job вместо partitioning). + +**S3. hash-chain trigger `audit_chain_hash()`.** Использует `TG_TABLE_NAME` в SELECT prev-hash — после партиционирования `TG_TABLE_NAME` будет именем **партиции** (`auth_log_y2026_m05`), значит цепочка станет **per-partition**. Это **семантически согласуется** с уже выкаченным fix'ом hole #1 «per-RLS-scope hash-chain validation» (commits `378cfba4`/`a195611d`) — там тоже было «несколько scopes на таблицу». Валидатор `VerifyAuditChains` нужно адаптировать чтобы он сканировал партиции отдельно. + +**S4. RLS на partitioned tables.** В PG 16 RLS-политики на parent НЕ наследуются партициями автоматически — нужно явно создавать на каждой партиции ИЛИ использовать parent-level policy через `ENABLE ROW LEVEL SECURITY` на parent + дублирование на партициях через cron. Это уже решено для `deals` (parent имеет RLS, partitions наследуют) — проверить в schema.sql точный паттерн. + +**S5. Триггеры на partitioned tables.** `BEFORE INSERT` триггеры на parent **наследуются** в PG 16 (с версии 13+). Это работает. + +**S6. Indexes.** Создаются на parent → на партициях создаются автоматически как локальные. Уникальные индексы должны включать partition-key. + +--- + +## 2. Решение + +### 2.1. Стратегия миграции + +Поскольку нельзя `ALTER TABLE ... PARTITION BY`, миграция = **rewrite**: + +1. Переименовать старую таблицу: `auth_log → auth_log_old` +2. Создать новую partitioned: `CREATE TABLE auth_log (...) PARTITION BY RANGE (created_at)` +3. Создать партиции для прошлого + текущего + 2 будущих месяца: `auth_log_y2026_m01 ... auth_log_y2026_m07` +4. Скопировать данные: `INSERT INTO auth_log SELECT * FROM auth_log_old` +5. Восстановить FK/RLS/triggers/indexes на новую parent +6. `DROP TABLE auth_log_old` после verify + +### 2.2. Scope + +**Все 7 таблиц** — данных мало (731 row total), миграция дёшева. Если ждать пока вырастут — миграция станет дороже. + +**FK на webhook_log:** выбор **W1 (удалить FK)** — минимально, 0 строк сейчас, app-уровень целостности достаточен (insert в `failed_webhook_jobs` всегда сопровождается известным `webhook_log_id`, который мы только что создали в той же транзакции). + +### 2.3. Retention policy (новый cron-job) + +Без `DROP` старых партиций цель «не растут вечно» не достигнута. Нужен второй cron: + +- **`partitions:drop-expired`** — еженедельно (Sundays 03:00 МСК). +- Per-table retention из `system_settings` (есть прецедент `webhook_log_retention_days=90`): + - `auth_log_retention_months = 24` (152-ФЗ требует 6 лет для финансовых, но auth — security event, 2 года достаточно для расследования инцидентов) + - `activity_log_retention_months = 36` (бизнес-журнал — 3 года для разборок) + - `tenant_operations_log_retention_months = 24` (audit операций админа) + - `webhook_log_retention_months = 3` (raw payloads с ПДн — короткий срок) + - `balance_transactions_retention_months = 84` (7 лет — финансовые по ст.29 НК РФ) + - `pd_processing_log_retention_months = 36` (152-ФЗ — 3 года минимум для PD-обработки) + - `saas_admin_audit_log_retention_months = 84` (7 лет — действия админа = финансовые операции) + +Эти значения — **дефолты**, изменяемы через `system_settings`. Drop делается атомарно (`DROP TABLE ... CASCADE` партиции — не trigger'ит каскадов на parent, RLS не теряется). + +### 2.4. Hash-chain валидация после partitioning + +`VerifyAuditChains` (hole #1) — обновить чтобы сканировал партиции отдельно: + +- Старая логика: `SELECT MAX(id) FROM auth_log` + проверка SHA-256 цепочки от 1 до max. +- Новая логика: для каждой партиции (получить список через `pg_partitions`) — проверить цепочку внутри неё. Разрыв в одной партиции = инцидент с указанием партиции. + +Это семантически совместимо с уже выкаченным fix'ом per-RLS-scope (каждая партиция = свой scope, как saas-admin/tenant scope в auth_log). + +--- + +## 3. Фазы выкатки + +### Phase A — Разработка + dev-тесты (без прод) + +A.1. Spec (этот документ) +A.2. Plan (TDD-задачи) +A.3. Extension `MonthlyPartitionManager::PARTITIONED_TABLES` (whitelist +7) +A.4. Миграция rewrite для 7 таблиц (один файл, idempotent через `IF EXISTS`) +A.5. Обновить `db/schema.sql` — заменить 7 объявлений +A.6. Pest-тесты: partition создаётся / data копируется / RLS работает / FK удалён / hash-chain per-partition / `partitions:drop-expired` работает +A.7. Adapt `VerifyAuditChains` (per-partition scan) + тесты +A.8. Новая команда `partitions:drop-expired` + cron в `routes/console.php` + тесты +A.9. cspell/pint/regression + +### Phase B — Прод-выкатка (с явным approve заказчика на каждом шаге) + +B.1. **Полный dump БД** (`pg_dump`) → `/home/ubuntu/deploy-backups/pre-partitioning-{ts}.dump` +B.2. Verify dump: `pg_restore --list ... | wc -l` ≥ ожидаемое +B.3. Применить миграцию (одна транзакция): `BEGIN; ... 7 rewrite steps ...; COMMIT;` +B.4. Smoke: row count в каждой таблице соответствует pre-snapshot +B.5. Запустить `partitions:create-months` — должны создаться партиции на 2 месяца вперёд +B.6. Запустить `audit:verify-chains` — должна сработать новая per-partition логика, все chains intact +B.7. Watch incidents_log 1 час — нет ли спайков от партиционирования + +### Phase C — Documentation + +C.1. Update ПИЛОТ.md §6 п.11 — добавить под-пункт #2 +C.2. Update ЭТАЛОН.md — schema v8.31 +C.3. Update memory `project_7holes_audit_followup` — закрыть #2 +C.4. Update master tracker overview — финал + +--- + +## 4. Открытые вопросы для заказчика + +**OQ1. Scope:** партиционировать **все 7** или меньше? Рекомендация — все 7 (данных мало, дёшево, лучше сейчас чем через год). + +**OQ2. FK на webhook_log:** удалить FK (W1) или сохранить через композитный FK (W2)? Рекомендация — W1 (минимально, 0 рисков). + +**OQ3. Retention defaults** (§2.3) — устраивают цифры? + +- auth: 24 мес, activity: 36, tenant_ops: 24, webhook: 3, balance: 84, pd: 36, saas_admin: 84. + +**OQ4. Cron расписание** для `partitions:drop-expired` — Sundays 03:00 МСК или другое? + +**OQ5. Hash-chain семантика** — принять per-partition chain (рекомендация — да, совместимо с hole #1 per-scope логикой) или отказаться от partitioning hash-chain таблиц? + +--- + +## 5. Риски + +| Риск | Вероятность | Митигация | +|---|---|---| +| Потеря данных при rewrite | низкая | dump до миграции + транзакция + verify counts | +| Hash-chain разрыв | средняя | per-partition scope (семантически OK); validator adapt | +| RLS не наследуется | низкая | проверка паттерна на `deals` (уже работает) | +| FK ломает существующий код | низкая | grep `webhook_log_id` использования; 0 строк сейчас | +| Cron not running после deploy | низкая | smoke `schedule:list` + `scheduler_heartbeats` следит | +| Долгая блокировка таблиц | очень низкая | данных <1MB, миграция <1сек |