Files
Investidor-V2/app/Services/SectorReconciliationExecution.php

149 lines
9.0 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
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): 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(CheckedFile::read($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'], CheckedFile::sha256($sql)), 409, 'SQL diferente da revisão delimitada.');
$files['database/data/sector-reconciliation-20261018.sql'] = hash_file('sha256', $sql);
foreach (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));
}
/**
* @return array<array-key, mixed>
*/
private function target(): array
{
return (array) DB::connection()->selectOne('SELECT current_database() AS database, inet_server_port() AS port', [], false);
}
/**
* @param array<array-key, mixed> $target
*/
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(CheckedFile::read($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'] ?? '', CheckedFile::sha256($proof[$kind.'_path'])), 409);
}
}
}
/**
* @return array<array-key, mixed>
*/
public function preview(Request $request): array
{
$actor = $this->actor($request);
abort_if($request->query->count() > 0, 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;
}
/**
* @return array<array-key, mixed>
*/
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(CheckedFile::read(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];
}
}