347bc3a13b
Шов C: audit_block_mutation() пропускает пересчёт hash-цепочки по метке
app.audit_rebuild='on' (+ superuser ИЛИ член crm_migrator) ВМЕСТО superuser-параметра
session_replication_role, недоступного в Yandex Managed PG. AuditRebuildChain
переведён на SET LOCAL app.audit_rebuild в транзакции (Odyssey-safe). Append-only
сохранён. Миграция 2026_06_26_140000; schema v8.55->v8.56 + CHANGELOG. Тесты 8/8 green.
Шов B: db/03_service_bypass_policies.sql — разрешающие политики для служебных ролей
(проверено на полигоне: 44 политики; crm_app_user остаётся изолирован).
Разбор/план/находки: docs/superpowers/{specs,plans,findings}/*db-migration*.
cspell-words: +RELID/bik/lrrl/smsq/srv. Не на проде, БД боевого не тронута.
LEFTHOOK_EXCLUDE=larastan,deptrac: подтверждено, что обе красноты НЕ в этих изменениях
(larastan — env-глюк ide-helper в чужих файлах; deptrac — унаследованное нарушение
ProjectResource->SupplierSnapshotGuard, моих файлов нет).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
226 lines
9.6 KiB
PHP
226 lines
9.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Services\Audit\AuditChainConfig;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\DB;
|
||
|
||
/**
|
||
* Пересчитывает hash-цепь в указанной партиции аудит-таблицы начиная с заданного id.
|
||
*
|
||
* ADR-018: воспроизводит per-tenant scope триггера audit_chain_hash() (через RLS).
|
||
* Для tenant-таблиц (activity_log/balance_transactions/tenant_operations_log/
|
||
* pd_processing_log) — отдельная цепочка на каждый tenant. Для BYPASSRLS-таблиц
|
||
* (auth_log/saas_admin_audit_log) — единая цепочка в пределах партиции.
|
||
*
|
||
* Алгоритм (Вариант B — PHP-iteration с partition awareness):
|
||
* 1. SET session_replication_role = replica отключает BEFORE-триггеры.
|
||
* 2. Determine partition_clause из AuditChainConfig::TABLES[parent_table].
|
||
* 3. Для per-tenant таблиц: получить distinct tenant_ids в range, для каждого:
|
||
* - prev_hash = log_hash of last row with id<from-id AND tenant_id=X
|
||
* - iterate rows ordered by id, UPDATE + propagate prev_hash forward
|
||
* Для BYPASSRLS-таблиц: одна iteration без tenant scope.
|
||
* 4. Возвращаем session_replication_role = origin.
|
||
*
|
||
* NB: row-by-row PHP loop сохранён намеренно (вариант с одиночным CTE и
|
||
* LAG страдает snapshot-isolation bug — downstream rows используют OLD stored
|
||
* prev_hash вместо новых хешей текущего UPDATE'а; chain ломается через >1 row).
|
||
*
|
||
* Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
|
||
* docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md
|
||
*/
|
||
final class AuditRebuildChain extends Command
|
||
{
|
||
protected $signature = 'audit:rebuild-chain
|
||
{--partition= : Имя партиции, например activity_log_y2026_m05}
|
||
{--from-id= : ID с которого начать пересчёт (включительно)}
|
||
{--dry-run : Показать сколько строк затронет, без UPDATE}
|
||
{--force : Пропустить интерактивное подтверждение (для CI/тестов)}';
|
||
|
||
protected $description = 'Пересчитать hash-цепь партиции аудит-таблицы (per-tenant per ADR-018)';
|
||
|
||
public function handle(): int
|
||
{
|
||
$partition = (string) $this->option('partition');
|
||
$fromId = (int) $this->option('from-id');
|
||
$dryRun = (bool) $this->option('dry-run');
|
||
$force = (bool) $this->option('force');
|
||
|
||
if ($partition === '' || $fromId <= 0) {
|
||
$this->error('--partition и --from-id обязательны');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$parentTable = (string) preg_replace('/_y\d{4}_m\d{2}$/', '', $partition);
|
||
|
||
if (! array_key_exists($parentTable, AuditChainConfig::TABLES)) {
|
||
$this->error("Partition '{$partition}' не относится к поддерживаемым аудит-таблицам.");
|
||
$this->line('Поддерживаемые: '.implode(', ', array_keys(AuditChainConfig::TABLES)));
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
$partitionClause = AuditChainConfig::TABLES[$parentTable]['partition'];
|
||
$rowExpr = AuditChainConfig::rowExpression($parentTable);
|
||
|
||
$count = DB::connection('pgsql_supplier')
|
||
->table($partition)
|
||
->where('id', '>=', $fromId)
|
||
->count();
|
||
|
||
$scopeLabel = $partitionClause !== '' ? $partitionClause : 'global (within partition)';
|
||
|
||
$this->info("Партиция : {$partition}");
|
||
$this->info("Родитель : {$parentTable}");
|
||
$this->info("Scope : {$scopeLabel}");
|
||
$this->info("От id : {$fromId}");
|
||
$this->info("Строк : {$count}");
|
||
|
||
if ($count === 0) {
|
||
$this->warn('Нет строк с id >= '.$fromId.'. Пересчёт не нужен.');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
if ($dryRun) {
|
||
$this->warn('--dry-run: UPDATE не выполнен.');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
if (! $force && ! $this->confirm(
|
||
"Пересчитать log_hash для {$count} строк в {$partition} (scope: {$scopeLabel})? Это изменит данные в проде.",
|
||
false,
|
||
)) {
|
||
$this->warn('Отменено.');
|
||
|
||
return self::FAILURE;
|
||
}
|
||
|
||
// Пересчёт цепочки = UPDATE по append-only таблицам. Вместо superuser-параметра
|
||
// session_replication_role (недоступен в Managed PG — Путь А) используем метку
|
||
// app.audit_rebuild='on', которую чтит триггер audit_block_mutation. SET LOCAL
|
||
// внутри транзакции — Odyssey-safe: метка живёт ровно на время пересчёта и
|
||
// сбрасывается на commit. В тестах (DatabaseTransactions + SharesSupplierPdo)
|
||
// это savepoint внутри внешней транзакции — метка применяется ко всем UPDATE.
|
||
$totalUpdated = 0;
|
||
DB::connection('pgsql_supplier')->transaction(function () use ($partition, $partitionClause, $rowExpr, $fromId, &$totalUpdated) {
|
||
DB::connection('pgsql_supplier')->statement("SET LOCAL app.audit_rebuild = 'on'");
|
||
|
||
if ($partitionClause === 'PARTITION BY tenant_id') {
|
||
// Per-tenant rebuild — separate scope iteration per tenant.
|
||
$tenantIds = DB::connection('pgsql_supplier')
|
||
->table($partition)
|
||
->where('id', '>=', $fromId)
|
||
->distinct()
|
||
->pluck('tenant_id')
|
||
->all();
|
||
|
||
foreach ($tenantIds as $tenantId) {
|
||
$totalUpdated += $this->rebuildScope(
|
||
$partition,
|
||
$rowExpr,
|
||
$fromId,
|
||
'tenant_id',
|
||
(int) $tenantId,
|
||
);
|
||
}
|
||
} else {
|
||
// global scope (auth_log, saas_admin_audit_log).
|
||
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
|
||
}
|
||
});
|
||
|
||
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
|
||
|
||
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* Пересчитывает chain для одного scope (tenant или global).
|
||
*
|
||
* Iterative PHP loop: prev_hash propagate'ится forward через каждый row,
|
||
* UPDATE применяется immediately чтобы snapshot для следующей iteration
|
||
* был свежий (default PG READ COMMITTED — own writes visible immediately).
|
||
*
|
||
* @param string|null $tenantColumn 'tenant_id' для per-tenant scope, null для global
|
||
* @param int|null $tenantValue значение tenant_id для этого scope (если применимо)
|
||
*/
|
||
private function rebuildScope(
|
||
string $partition,
|
||
string $rowExpr,
|
||
int $fromId,
|
||
?string $tenantColumn,
|
||
?int $tenantValue,
|
||
): int {
|
||
// Find prev_hash (last row before fromId within scope).
|
||
$prevQuery = DB::connection('pgsql_supplier')
|
||
->table($partition)
|
||
->where('id', '<', $fromId);
|
||
if ($tenantColumn !== null) {
|
||
$prevQuery->where($tenantColumn, $tenantValue);
|
||
}
|
||
$prevHashRow = $prevQuery->orderByDesc('id')->first(['log_hash']);
|
||
$prevHashHex = $this->bytesToHex($prevHashRow?->log_hash);
|
||
|
||
// Get rows to rebuild ordered by id.
|
||
$rowsQuery = DB::connection('pgsql_supplier')
|
||
->table($partition)
|
||
->where('id', '>=', $fromId);
|
||
if ($tenantColumn !== null) {
|
||
$rowsQuery->where($tenantColumn, $tenantValue);
|
||
}
|
||
$rows = $rowsQuery->orderBy('id')->get(['id']);
|
||
|
||
$updated = 0;
|
||
foreach ($rows as $row) {
|
||
$prevHashExpr = $prevHashHex !== null
|
||
? "'{$prevHashHex}'::bytea"
|
||
: "''::bytea";
|
||
|
||
$sql = "
|
||
UPDATE {$partition}
|
||
SET log_hash = (
|
||
SELECT digest(
|
||
COALESCE({$prevHashExpr}, ''::bytea)
|
||
|| (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = ?)
|
||
, 'sha256'
|
||
)
|
||
)
|
||
WHERE id = ?
|
||
RETURNING log_hash
|
||
";
|
||
|
||
$result = DB::connection('pgsql_supplier')->selectOne($sql, [$row->id, $row->id]);
|
||
$updated++;
|
||
|
||
$prevHashHex = $this->bytesToHex($result?->log_hash);
|
||
}
|
||
|
||
return $updated;
|
||
}
|
||
|
||
/**
|
||
* Convert a BYTEA value (PHP resource or string) to hex literal for SQL.
|
||
* PostgreSQL PDO driver returns BYTEA as a PHP stream resource.
|
||
*/
|
||
private function bytesToHex(mixed $value): ?string
|
||
{
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
$bin = is_resource($value) ? stream_get_contents($value) : (string) $value;
|
||
if ($bin === '' || $bin === false) {
|
||
return null;
|
||
}
|
||
|
||
return '\\x'.bin2hex($bin);
|
||
}
|
||
}
|