122 lines
8.6 KiB
PHP
122 lines
8.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
/** Existing session, permission and MFA control; no actor argument or console entry point. */
|
|
final class SectorReconciliationExecution
|
|
{
|
|
public const SOURCE = 'sector-reconciliation-authenticated';
|
|
public const OPERATION = 'sector-reconciliation-20261018';
|
|
|
|
private function actor(Request $request): \App\Models\User
|
|
{
|
|
abort_if(app()->runningInConsole(), 403, 'A execução exige a sessão HTTP autenticada existente.');
|
|
$actor = Auth::guard('web')->user();
|
|
abort_unless($request->hasSession() && $actor && $request->user()?->id === $actor->id, 401);
|
|
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403);
|
|
// Same installation permission and Fortify session MFA used by 140000/150000.
|
|
app(PortalProfileInstallation::class)->authorize($actor, true);
|
|
abort_unless((int)$request->session()->get('portal.mfa_at', 0) <= time(), 403, 'MFA da sessão inválido.');
|
|
|
|
return $actor;
|
|
}
|
|
|
|
public function revision(): string
|
|
{
|
|
$file = database_path('data/sector-reconciliation-execution.json');
|
|
$manifest = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
|
|
$files = ['database/data/sector-reconciliation-execution.json' => hash_file('sha256', $file)];
|
|
foreach ($manifest['files'] as $path) $files[$path] = hash_file('sha256', base_path($path));
|
|
$sql = database_path('data/sector-reconciliation-20261018.sql');
|
|
abort_unless(hash_equals($manifest['sql_sha256'], hash_file('sha256', $sql)), 409, 'SQL diferente da revisão delimitada.');
|
|
$files['database/data/sector-reconciliation-20261018.sql'] = hash_file('sha256', $sql);
|
|
foreach (\Illuminate\Support\Facades\File::allFiles(public_path('build')) as $build) $files['public/build/'.$build->getRelativePathname()] = hash_file('sha256', $build->getPathname());
|
|
ksort($files);
|
|
|
|
return hash('sha256', json_encode($files, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
private function target(): array
|
|
{
|
|
return (array) DB::connection()->selectOne('SELECT current_database() AS database, inet_server_port() AS port', [], false);
|
|
}
|
|
|
|
private function readiness(string $revision, array $target): void
|
|
{
|
|
$p = storage_path('app/private/investmoz-v2/sector-reconciliation/readiness.json');
|
|
$proof = is_file($p) ? json_decode(file_get_contents($p), true, 512, JSON_THROW_ON_ERROR) : [];
|
|
abort_unless(($proof['operation'] ?? '') === self::OPERATION && ($proof['revision'] ?? '') === $revision
|
|
&& ($proof['target'] ?? []) === $target && !empty($proof['authorization_record']), 409, 'Préflight/autorização da revisão e destino necessários.');
|
|
$isolated = app()->environment('testing') && (int)$target['port'] === 55441
|
|
&& str_ends_with($target['database'], '_test') && config('database.connections.pgsql.host') === '127.0.0.1'
|
|
&& !config('database.connections.pgsql.url');
|
|
if (!$isolated) {
|
|
abort_unless(($proof['restore_verified'] ?? false) && ($proof['restored_boot_verified'] ?? false)
|
|
&& strtotime($proof['verified_at'] ?? '') >= time() - 86400 && strtotime($proof['verified_at'] ?? '') <= time(), 409, 'Backup/restauro fresco do procedimento existente necessário.');
|
|
foreach (['dump', 'files'] as $kind) abort_unless(is_file($proof[$kind.'_path'] ?? '')
|
|
&& hash_equals($proof[$kind.'_sha256'] ?? '', hash_file('sha256', $proof[$kind.'_path'])), 409);
|
|
}
|
|
}
|
|
|
|
public function preview(Request $request): array
|
|
{
|
|
$actor = $this->actor($request);
|
|
abort_if($request->query->count(), 422, 'Ator, revisão ou opções por URL recusados.');
|
|
$revision = $this->revision(); $target = $this->target(); $this->readiness($revision, $target);
|
|
$state = ['operation' => self::OPERATION, 'revision' => $revision, 'target' => $target,
|
|
'actor' => ['id' => $actor->id, 'name' => $actor->name], 'migrations' => [], 'ready' => true];
|
|
$state['hash'] = hash_hmac('sha256', json_encode([$actor->id, $request->session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'));
|
|
|
|
return $state;
|
|
}
|
|
|
|
public function apply(Request $request): array
|
|
{
|
|
$actor = $this->actor($request);
|
|
abort_if(array_diff(array_keys($request->all()), ['hash', 'operation_key', 'confirm', '_token']) || $request->query->count(), 422, 'Ator, revisão, SQL ou parâmetros arbitrários recusados.');
|
|
$data = Validator::make($request->all(), ['hash' => 'required|string|size:64', 'operation_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
|
|
$db = DB::connection();
|
|
abort_unless($db->transactionLevel() === 0, 409, 'A operação deve possuir a sua transação.');
|
|
$revision = $this->revision(); $target = $this->target(); $this->readiness($revision, $target);
|
|
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
|
|
|
|
$result = $db->transaction(function () use ($db, $actor, $data, $request, $revision, $target, $requestHash) {
|
|
$db->selectOne('SELECT pg_advisory_xact_lock(181026, 4)', [], false);
|
|
$old = $db->table('audit_events')->useWritePdo()->where('source_system', self::SOURCE)->where('correlation_id', $data['operation_key'])->first();
|
|
if ($old) {
|
|
$saved = json_decode($old->after_data, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless((int)$old->actor_user_id === $actor->id && ($saved['revision'] ?? '') === $revision
|
|
&& hash_equals($saved['request_hash'], $requestHash), 409);
|
|
return [...$saved['receipt'], 'replayed' => true];
|
|
}
|
|
$plan = $this->preview($request);
|
|
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia diferente da sessão/revisão atual.');
|
|
$before = $db->selectOne("SELECT pg_backend_pid() AS pid, txid_current()::text AS txid, NULLIF(current_setting('investmoz.actor_user_id',true),'') AS actor", [], false);
|
|
abort_unless($before->actor === null, 409, 'Ligação contém um ator anterior; operação recusada.');
|
|
// true means SET LOCAL. Same write connection and transaction execute every statement.
|
|
$db->selectOne("SELECT set_config('investmoz.actor_user_id',?,true), set_config('investmoz.operation_key',?,true), set_config('investmoz.authorized_revision',?,true)", [(string)$actor->id, $data['operation_key'], $revision], false);
|
|
$oldDataEvent = $db->table('audit_events')->useWritePdo()->where('source_system', self::OPERATION)->exists();
|
|
$db->getPdo()->exec(file_get_contents(database_path('data/sector-reconciliation-20261018.sql')));
|
|
$scope = $db->selectOne("SELECT pg_backend_pid() AS pid, txid_current()::text AS txid, current_setting('investmoz.actor_user_id') AS actor", [], false);
|
|
abort_unless($scope->pid === $before->pid && $scope->txid === $before->txid && (int)$scope->actor === $actor->id, 500);
|
|
$receipt = ['operation' => self::OPERATION, 'operation_key' => $data['operation_key'], 'revision' => $revision,
|
|
'actor_user_id' => $actor->id, 'target' => $target, 'changed' => !$oldDataEvent, 'opportunity_id' => 2,
|
|
'sector_id' => 8, 'replayed' => false, 'connection_pid' => $scope->pid, 'transaction_id' => $scope->txid];
|
|
$db->table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'sector-reconciliation', 'action' => 'execute',
|
|
'actor_user_id' => $actor->id, 'correlation_id' => $data['operation_key'], 'data_classification' => 'internal',
|
|
'after_data' => json_encode(['operation' => self::OPERATION, 'revision' => $revision, 'request_hash' => $requestHash, 'receipt' => $receipt], JSON_THROW_ON_ERROR)]);
|
|
|
|
return $receipt;
|
|
});
|
|
$after = $db->selectOne("SELECT pg_backend_pid() AS pid, NULLIF(current_setting('investmoz.actor_user_id',true),'') AS actor, NULLIF(current_setting('investmoz.operation_key',true),'') AS operation, NULLIF(current_setting('investmoz.authorized_revision',true),'') AS revision", [], false);
|
|
abort_unless($after->actor === null && $after->operation === null && $after->revision === null, 500, 'Contexto local não terminou com a transação.');
|
|
|
|
return [...$result, 'local_settings_cleared' => true, 'connection_after_commit_pid' => $after->pid];
|
|
}
|
|
}
|