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:
Дмитрий
2026-05-23 15:50:37 +03:00
parent a299377fd7
commit 60ab5be3eb
20 changed files with 1247 additions and 167 deletions
@@ -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}");
}
}
+92 -73
View File
@@ -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()}");
}
}
}
+7 -3
View File
@@ -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(),
+71 -10
View File
@@ -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 без триггеров.
+10
View File
@@ -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-таблицах
});
+4
View File
@@ -1706,3 +1706,7 @@ FNS
дебаг
валидируется
рендериться
# Hole #2 partitioning (23.05.2026)
партиционировать
дёшева
+46 -2
View File
@@ -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
View File
@@ -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сек |