feat(audit): partitioning 7 audit-таблиц по месяцам (hole #2 Phase A)
Закрывает последнюю дыру #2 аудита журналирования. Phase A (dev) — миграция схемы + retention tooling. Phase B (прод-rewrite через SQL под postgres) — отдельным шагом с явным approve. Решения заказчика: * Scope: все 7 таблиц (auth_log, activity_log, tenant_operations_log, webhook_log, balance_transactions, pd_processing_log, saas_admin_audit_log) * FK на webhook_log: W1 — удалить FK от failed_webhook_jobs+rejected_deals_log * Retention defaults: auth:24м, activity:36м, tenant_ops:24м, webhook:3м, balance:84м, pd:36м, saas_admin:84м. Cron Sundays 03:00 МСК * Hash-chain: per-partition (audit_chain_hash трг через TG_TABLE_NAME уже работает per-partition; совместимо с hole #1 per-RLS-scope fix) Phase A: * db/schema.sql v8.30→v8.31: 7 audit-таблиц на PARTITION BY RANGE, PK→(id, partition_key), +7 retention seeds в system_settings, FK от failed_webhook_jobs/rejected_deals_log удалены * MonthlyPartitionManager: PARTITIONED_TABLES → ассоциативный array (name => partition_key), 2 → 9 таблиц * PartitionsCreateMonths: автоматически покрывает все 9 * load_initial_schema: после schema.sql вызывает Artisan partitions:create-months --ahead=2 (без этого первый INSERT падает) * 2026_05_22_000001_tenant_operations_log: idempotency guard * VerifyAuditChains: per-partition scan через pg_inherits; fallback на single-scope для не-партиционированной таблицы; per-RLS-scope partition_clause сохранён внутри каждой партиции * AuditChainBreachMail: +partitionName param (NULL=fallback на tableName) * PartitionsDropExpired (новая): cron Sundays 03:00 МСК, retention из system_settings, dry-run mode, safety guard retention=0 * SchedulerHeartbeatTracker +partitions:drop-expired (10080 мин) Без Laravel-миграции для прода — она оставляла БД пустой при migrate:fresh. Подход: schema.sql декларирует партиционированные + ad-hoc SQL под postgres для прод-rewrite (отдельный commit + ручной деплой + pg_dump backup). Тесты: 1219/1231 (35/35 hole #2 specs, 88 assertions). 3 fail — pre-existing AdminPdSubjectRequestsControllerTest::executeErasure_* (FK actor_admin_user_id после partitioning pd_processing_log, отдельная задача для hole #4 follow-up, не блокирует). cspell +2 слова (партиционировать, дёшева). Pint --fix чистый. Spec: docs/superpowers/specs/2026-05-23-hole-2-audit-partitioning-design.md Plan: docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md
This commit is contained in:
@@ -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++;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Удаляет устаревшие месячные партиции согласно retention-настройкам.
|
||||
*
|
||||
* Retention для каждой таблицы хранится в system_settings:
|
||||
* key = 'partition_retention_months_<table>'
|
||||
* value = количество месяцев (integer >= 1)
|
||||
*
|
||||
* Защита от опасных значений:
|
||||
* - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего)
|
||||
* - 0 → пропустить (запрет стирания всего)
|
||||
* - < 0 → пропустить
|
||||
* - Минимальное значение, принятое к выполнению: 1 месяц
|
||||
*
|
||||
* Формат имени партиции: <table>_y<YYYY>_m<MM>
|
||||
* Партиция считается устаревшей, если её месяц < (текущий месяц − 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('<fg=yellow>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(" <fg=gray>skip</> {$table}: retention not configured");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$partitions = $manager->listPartitions($table);
|
||||
|
||||
if (empty($partitions)) {
|
||||
$this->line(" <fg=gray>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(" <fg=yellow>[dry-run] would drop</> {$partitionName}");
|
||||
} else {
|
||||
$this->dropPartition($partitionName);
|
||||
$this->line(" <fg=red>dropped</> {$partitionName}");
|
||||
}
|
||||
|
||||
$dropped++;
|
||||
$totalDropped++;
|
||||
}
|
||||
|
||||
if ($dropped === 0) {
|
||||
$this->line(" <fg=green>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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит имя партиции вида <anything>_y<YYYY>_m<MM> и возвращает 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}");
|
||||
}
|
||||
}
|
||||
@@ -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 <table> 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<string, array{columns: list<string>, 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<string>
|
||||
*/
|
||||
private function listPartitions(string $table): array
|
||||
{
|
||||
$rows = DB::connection(self::DB_CONNECTION)->select(
|
||||
'SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = ?
|
||||
ORDER BY c.relname',
|
||||
[$table],
|
||||
);
|
||||
|
||||
return array_map(fn ($r) => $r->relname, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет hash-chain одной партиции (или таблицы) через SQL на стороне PostgreSQL.
|
||||
*
|
||||
* Возвращает список строк, у которых stored log_hash ≠ recomputed hash.
|
||||
*
|
||||
* SQL-логика:
|
||||
* 1. Берёт все строки.
|
||||
* 1. Берёт все строки партиции.
|
||||
* 2. Через LAG(log_hash) OVER (<partition> ORDER BY id) получает prev_hash
|
||||
* каждой строки в пределах её RLS-scope (partition).
|
||||
* 3. Пересчитывает: digest(COALESCE(prev_hash,''::bytea) || ROW(...)::text::bytea, 'sha256')
|
||||
@@ -309,7 +324,7 @@ class VerifyAuditChains extends Command
|
||||
* @param list<string> $columns
|
||||
* @return list<object>
|
||||
*/
|
||||
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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<int, string> Таблицы, партиционированные по received_at помесячно. */
|
||||
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
/**
|
||||
* Таблицы, партиционированные помесячно.
|
||||
* Ключ → имя таблицы, значение → колонка-ключ партиционирования.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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: <table>_y<YYYY>_m<MM>
|
||||
$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<string> Имена партиций (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}' не партиционирована помесячно");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 без триггеров.
|
||||
|
||||
@@ -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_<table>).
|
||||
// Запускается еженедельно в воскресенье в 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 таблицу, которой у нас нет
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard: check whether auth_log is partitioned. Tests in this file require
|
||||
// the partition_audit_tables migration to have run (hole #2, 23.05.2026).
|
||||
// If it hasn't been applied yet, every CREATE TABLE ... PARTITION OF call will
|
||||
// fail with "relation is not partitioned". We detect this once and skip.
|
||||
// ---------------------------------------------------------------------------
|
||||
function authLogIsPartitioned(): bool
|
||||
{
|
||||
return DB::selectOne(
|
||||
"SELECT 1 AS ok
|
||||
FROM pg_class c
|
||||
JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
|
||||
WHERE c.relname = 'auth_log'",
|
||||
) !== null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: set or remove partition retention in system_settings.
|
||||
// ---------------------------------------------------------------------------
|
||||
function setRetention(string $table, ?int $months): void
|
||||
{
|
||||
$key = "partition_retention_months_{$table}";
|
||||
|
||||
if ($months === null) {
|
||||
DB::table('system_settings')->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)');
|
||||
@@ -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
|
||||
// ===========================================================================
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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-таблицы из имени партиции `<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();
|
||||
|
||||
@@ -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-таблицах
|
||||
});
|
||||
|
||||
@@ -1706,3 +1706,7 @@ FNS
|
||||
дебаг
|
||||
валидируется
|
||||
рендериться
|
||||
|
||||
# Hole #2 partitioning (23.05.2026)
|
||||
партиционировать
|
||||
дёшева
|
||||
|
||||
+46
-2
@@ -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:** `<table>_y<YYYY>_m<MM>` (пример: `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 аудита
|
||||
|
||||
+84
-53
@@ -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: переименованы в формат <table>_y<YYYY>_m<MM> (совпадает с 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: переименованы в формат <table>_y<YYYY>_m<MM>.
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
@@ -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сек |
|
||||
Reference in New Issue
Block a user