Files
Investidor-V2/app/Services/EconomicReferenceInstallation.php
T
nala 2d23157e40
tests / ci (push) Canceled after 0s
versao nrgocios
2026-10-16 13:06:58 +02:00

292 lines
22 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Bounded authenticated extension. No generic seeding, arbitrary path or caller-supplied actor. */
class EconomicReferenceInstallation
{
public const MIGRATION = '2026_10_16_120000_extend_economic_reference_hierarchy';
public const OPERATIONS = ['schema', 'import', 'publish', 'withdraw'];
public function files(): array
{
return ['app/Services/EconomicReferenceCatalog.php', 'app/Services/EconomicReferenceInstallation.php',
'app/Http/Controllers/EconomicReferenceController.php', 'app/Services/OpportunityCanonicalContract.php',
'app/Services/OpportunityClassification.php', 'app/Services/ExistingBusinessClassification.php',
'app/Http/Controllers/Api/V1/ReferenceCatalogController.php', 'resources/js/components/opportunity-canonical-fields.tsx',
'resources/js/components/opportunity-classification.tsx', 'resources/js/pages/management/economic-references.tsx',
'routes/web.php', 'database/migrations/'.self::MIGRATION.'.php', 'database/data/economic-references-lot-01.json'];
}
public function fingerprint(): string
{
return hash('sha256', json_encode(array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files()), JSON_THROW_ON_ERROR));
}
private function directory(): string { return storage_path('app/private/investmoz-v2/economic-references/lot-01'); }
private function target(): array
{
return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
}
private function qa(array $target): bool
{
return app()->environment('testing') && str_ends_with($target['database'], '_test') && (int) $target['port'] === 55441
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
}
private function authorize(User $actor, bool $write = false, string $operation = ''): void
{
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal'), 403, 'Acesso reservado à gestão autorizada.');
if (! $write) { return; }
$access = app(PortalProfileAccess::class);
abort_if($access->has($actor, 'auditor'), 403, 'A restrição de Auditor permite apenas consulta.');
abort_unless($access->mfa($actor), 403, 'Conclua MFA nesta sessão para executar esta ação.');
if ($operation === 'schema') { app(PortalProfileInstallation::class)->authorize($actor, true); }
abort_unless(! app()->environment('testing') || $this->qa($this->target()), 403, 'Alvo QA isolado inválido.');
}
private function evidence(array $target, string $code, string $operation): array
{
if ($this->qa($target)) { return ['ready' => true, 'mode' => 'isolated_QA', 'date' => null]; }
if (app()->environment('testing')) { return ['ready' => false, 'mode' => 'invalid_QA', 'date' => null]; }
$path = $this->directory().'/readiness.json';
$proof = is_file($path) ? json_decode(file_get_contents($path), true) : [];
$date = strtotime($proof['verified_at'] ?? '');
$permission = $proof['active_authorization'] ?? [];
$ready = ! config('database.connections.pgsql.url') && in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
&& $target['database'] === 'investemoz' && (int) $target['port'] === 5432
&& ($proof['target']['database'] ?? '') === $target['database'] && (int) ($proof['target']['port'] ?? 0) === (int) $target['port']
&& ($proof['code_hash'] ?? '') === $code && ($proof['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256
&& $date !== false && $date <= time() && $date >= time() - 86400
&& ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false)
&& ($proof['qa_passed'] ?? false) && ($proof['compatibility_verified'] ?? false)
&& str_ends_with($proof['restore_target']['database'] ?? '', '_test') && (int) ($proof['restore_target']['port'] ?? 0) === 55441
&& is_file($proof['dump_path'] ?? '') && is_file($proof['files_path'] ?? '')
&& hash_equals($proof['dump_sha256'] ?? '', hash_file('sha256', $proof['dump_path']))
&& hash_equals($proof['files_sha256'] ?? '', hash_file('sha256', $proof['files_path']))
&& ($permission['decision_record'] ?? '') !== '' && ($permission['code_hash'] ?? '') === $code
&& ($permission['manifest_sha256'] ?? '') === EconomicReferenceCatalog::MANIFEST_SHA256
&& in_array($operation, $permission['operations'] ?? [], true);
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'date' => $proof['verified_at'] ?? null,
'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/** No source scan/write in preview. Paths are fixed, private and confined to this lot. */
private function sources(array $manifest): array
{
$root = realpath($this->directory()); $sources = [];
foreach ($manifest['sources'] as $entry) {
$path = $root ? realpath($root.DIRECTORY_SEPARATOR.$entry['relative_path']) : false;
$inside = $path && str_starts_with(strtolower(str_replace('\\', '/', $path)), strtolower(str_replace('\\', '/', $root)).'/');
$matches = $inside && is_file($path) && filesize($path) === $entry['bytes'] && hash_equals($entry['sha256'], hash_file('sha256', $path));
$sources[] = ['name' => $entry['name'], 'sha256' => $entry['sha256'], 'bytes' => $entry['bytes'], 'ready' => (bool) $matches];
}
return $sources;
}
private function referenceRows(array $manifest): array
{
$rows = [['table' => 'reference_catalog_versions', 'data' => $manifest['catalog']]];
foreach (['activities' => 'economic_activities', 'chains' => 'value_chains', 'links' => 'value_chain_links'] as $group => $table) {
foreach ($manifest[$group] as $row) { $rows[] = ['table' => $table, 'data' => $row]; }
}
return $rows;
}
private function createdHere(string $id): bool
{
return DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('action', 'import')
->whereRaw("after_data->'result'->'changed' @> ?::jsonb", [json_encode([$id])])->exists();
}
private function references(array $manifest, bool $schemaReady): array
{
$results = [];
foreach ($this->referenceRows($manifest) as $row) {
$expected = $row['data']; $table = $row['table'];
$current = DB::table($table)->where('id', $expected['id'])->first();
$conflict = false;
foreach ($expected as $key => $value) {
if (! $current || ($key === 'metadata' && ! $schemaReady)) { continue; }
if ($key === 'status') { $conflict = $conflict || ! in_array($current->$key, ['draft', 'published'], true); continue; }
if ($key === 'data_classification') { $conflict = $conflict || ! in_array($current->$key, ['internal', 'public'], true); continue; }
$actual = $key === 'metadata' ? json_decode($current->$key, true) : $current->$key;
if (OpportunityCanonicalValue::hash($actual) !== OpportunityCanonicalValue::hash($value)) { $conflict = true; }
}
$identity = DB::table($table)->where('id', '!=', $expected['id']);
if ($table === 'reference_catalog_versions') { $identity->where('catalog_code', $expected['catalog_code'])->where('version_no', $expected['version_no'])->where('locale', $expected['locale']); }
elseif ($table === 'economic_activities') { $identity->where('catalog_version_id', $expected['catalog_version_id'])->where('code', $expected['code']); }
else { $identity->where('source_system', $expected['source_system'])->where('external_id', $expected['external_id']); }
$conflict = $conflict || $identity->exists();
if ($table === 'value_chains') {
$conflict = $conflict || DB::table($table)->where('id', '!=', $expected['id'])->where('cluster_id', $expected['cluster_id'])->where('code', $expected['code'])->whereNull('deleted_at')->exists();
}
$results[] = ['table' => $table, 'id' => $expected['id'], 'code' => $expected['code'] ?? $expected['external_id'] ?? $expected['catalog_code'],
'status' => $conflict ? 'conflict' : ($current ? 'reuse' : 'insert'), 'current_hash' => $current ? OpportunityCanonicalValue::hash((array) $current) : null,
'current_visibility' => $current?->status ?? $current?->data_classification ?? null];
}
return $results;
}
public function preview(User $actor): array
{
$this->authorize($actor);
$catalog = app(EconomicReferenceCatalog::class); $manifest = $catalog->manifest();
$target = $this->target(); $code = $this->fingerprint();
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$column = $catalog->schemaAvailable();
$unique = DB::selectOne("SELECT 1 FROM pg_constraint WHERE conrelid='public.economic_activities'::regclass AND conname='economic_activities_sector_uq'") !== null;
$sectorNullable = DB::selectOne("SELECT is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='economic_activities' AND column_name='sector_id'")?->is_nullable === 'YES';
$index = DB::selectOne("SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='economic_activities_sector_idx'") !== null;
$installed = $ledger && $column && ! $unique && $sectorNullable && $index;
$consistent = $installed || (! $ledger && ! $column && $unique && ! $sectorNullable && ! $index);
$rows = $this->references($manifest, $column); $sources = $this->sources($manifest); $reasons = [];
foreach (['sector' => 'sectors', 'cluster' => 'economic_clusters'] as $name => $table) {
$expected = $manifest['existing'][$name]; $record = DB::table($table)->where('id', $expected['id'])->whereNull('deleted_at')->first();
foreach (array_filter($expected, fn ($v) => $v !== null) as $field => $value) {
if (! $record || (string) ($record->$field ?? '') !== (string) $value) { $reasons[] = 'Identidade/atributos do '.$name.' divergentes do manifesto; não foi usada semelhança de nomes.'; break; }
}
}
if (! $consistent) { $reasons[] = 'Ledger e esquema divergentes; reconciliar antes de instalar.'; }
if (collect($rows)->contains('status', 'conflict')) { $reasons[] = 'Conflito de identidade/versão: não será aplicado parcialmente.'; }
if (collect($sources)->contains('ready', false)) { $reasons[] = 'Exemplar privado ausente ou com bytes diferentes.'; }
$imported = ! collect($rows)->contains(fn ($r) => $r['status'] !== 'reuse');
$scanPath = $this->directory().'/scans.json';
$scan = is_file($scanPath) ? json_decode(file_get_contents($scanPath), true) : null;
$state = ['target' => $target, 'code_hash' => $code, 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256,
'migration' => self::MIGRATION, 'installed' => $installed, 'consistent' => $consistent, 'imported' => $imported,
'publication' => $catalog->publicationState(), 'rows' => $rows, 'sources' => $sources, 'scan' => $scan,
'divergence' => $manifest['divergence'], 'convention' => $manifest['convention'], 'common_reasons' => $reasons];
$operations = [];
foreach (self::OPERATIONS as $operation) {
$why = $reasons;
try { $this->authorize($actor, true, $operation); } catch (HttpException $e) { $why[] = $e->getMessage(); }
$evidence = $this->evidence($target, $code, $operation);
if (! $evidence['ready']) { $why[] = 'Decisão própria do ativo e prova fresca desta revisão/alvo/backup/restauro necessárias.'; }
if ($operation !== 'schema' && ! $installed) { $why[] = 'Instale primeiro o esquema nominal.'; }
if (in_array($operation, ['publish', 'withdraw'], true) && ! $imported) { $why[] = 'Importação privada completa necessária antes de publicar/retirar.'; }
if (in_array($operation, ['publish', 'withdraw'], true) && ! DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('action', 'import')->where('after_data->result->manifest_sha256', EconomicReferenceCatalog::MANIFEST_SHA256)->exists()) {
$why[] = 'Execute a importação privada autenticada, inclusive para registos reutilizados.';
}
if ($operation === 'withdraw' && $state['publication'] === 'private') { $why[] = 'O lote ainda não teve publicação para retirar.'; }
if ($operation === 'withdraw' && collect($rows)->contains(fn ($r) => $r['status'] === 'reuse'
&& in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id']))) {
$why[] = 'Há referências anteriores já públicas reutilizadas: reconciliar o mapa de retirada antes de agir.';
}
$token = hash_hmac('sha256', OpportunityCanonicalValue::hash([$actor->id, session()->getId(), $operation, $state, $evidence]), config('app.key'));
$operations[$operation] = ['ready' => $why === [], 'reasons' => array_values(array_unique($why)), 'hash' => $token, 'backupDate' => $evidence['date'], 'evidence_mode' => $evidence['mode']];
}
return [...$state, 'operations' => $operations];
}
private function scan(array $manifest): array
{
$directory = realpath($this->directory()); $records = [];
abort_unless($directory, 422, 'Área privada do lote indisponível.');
$paths = [['name' => 'manifesto', 'path' => database_path('data/economic-references-lot-01.json'), 'sha256' => EconomicReferenceCatalog::MANIFEST_SHA256]];
foreach ($manifest['sources'] as $source) { $paths[] = ['name' => $source['name'], 'path' => $directory.'/'.$source['relative_path'], 'sha256' => $source['sha256']]; }
foreach ($paths as $file) {
abort_unless(is_file($file['path']) && hash_equals($file['sha256'], hash_file('sha256', $file['path'])), 409, 'Originais alterados após prévia.');
// Scanner's path confinement uses the private source directory; manifest is scanned via a private exact copy.
$path = $file['path'];
if ($file['name'] === 'manifesto') { $path = $directory.'/manifest-scanned.json'; file_put_contents($path, file_get_contents($file['path'])); }
$result = app(MalwareScanner::class)->scan($path, filesize($path), $directory);
if ($result['signatures_at'] instanceof \DateTimeInterface) { $result['signatures_at'] = $result['signatures_at']->format(DATE_ATOM); }
$records[] = ['name' => $file['name'], 'sha256' => $file['sha256'], 'scan' => $result];
abort_unless($result['result'] === 'clean', 423, 'Scan não concluído ou não limpo para '.$file['name'].'. Nenhuma referência foi importada/publicada.');
abort_unless(hash_equals($file['sha256'], hash_file('sha256', $file['path'])) && hash_equals($file['sha256'], hash_file('sha256', $path)), 409, 'Bytes alterados durante o scan.');
}
$proof = ['at' => now()->toIso8601String(), 'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'files' => $records];
file_put_contents($directory.'/scans.json', json_encode($proof, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
return $proof;
}
public function apply(User $actor, string $operation, array $input): array
{
abort_unless(in_array($operation, self::OPERATIONS, true), 404);
$this->authorize($actor, true, $operation);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', 'reason', '_token']), 422, 'Campo de ator, manifesto ou conteúdo arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted', 'reason' => 'required|string|min:20|max:1000'])->validate();
return DB::transaction(function () use ($actor, $operation, $data) {
DB::select('SELECT pg_advisory_xact_lock(161026,120000)');
$requestHash = OpportunityCanonicalValue::hash([$operation, $data]);
$old = DB::table('audit_events')->where('source_system', EconomicReferenceCatalog::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if ($old) {
$receipt = json_decode($old->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave de repetição já usada com ator/conteúdo diferentes.');
return [...$receipt['result'], 'replayed' => true, 'current_publication' => app(EconomicReferenceCatalog::class)->publicationState()];
}
$plan = $this->preview($actor); $phase = $plan['operations'][$operation];
abort_unless(hash_equals($phase['hash'], $data['hash']), 409, 'Alvo, ator, revisão ou estado alterados; consulte nova prévia.');
abort_unless($phase['ready'], 422, implode(' ', $phase['reasons']));
$manifest = app(EconomicReferenceCatalog::class)->manifest(); $changed = []; $reused = [];
foreach ($this->referenceRows($manifest) as $row) { DB::table($row['table'])->where('id', $row['data']['id'])->lockForUpdate()->first(); }
DB::table('sectors')->where('id', $manifest['existing']['sector']['id'])->lockForUpdate()->first();
DB::table('economic_clusters')->where('id', $manifest['existing']['cluster']['id'])->lockForUpdate()->first();
// Recheck after locks in case another approved writer changed a reused master.
abort_unless($this->preview($actor)['operations'][$operation]['hash'] === $phase['hash'], 409, 'Referências alteradas durante a preparação da operação.');
if ($operation === 'schema' && ! $plan['installed']) {
config(['economic_references.authenticated_installation' => true]);
try { abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na migration nominal.'); }
finally { config(['economic_references.authenticated_installation' => false]); }
abort_unless($this->preview($actor)['installed'], 500, 'Esquema instalado não corresponde ao plano.');
$changed[] = self::MIGRATION;
} elseif ($operation === 'import') {
$scan = $this->scan($manifest);
foreach ($this->referenceRows($manifest) as $i => $row) {
if ($plan['rows'][$i]['status'] === 'reuse') { $reused[] = $row['data']['id']; continue; }
$values = $row['data']; if (isset($values['metadata'])) { $values['metadata'] = json_encode($values['metadata'], JSON_THROW_ON_ERROR); }
DB::table($row['table'])->insert([...$values, 'created_at' => now(), 'updated_at' => now()]);
$changed[] = $values['id'];
}
} elseif ($operation === 'publish' || $operation === 'withdraw') {
if ($operation === 'publish') { $scan = $this->scan($manifest); }
foreach ($this->referenceRows($manifest) as $row) {
$table = $row['table']; $record = DB::table($table)->where('id', $row['data']['id'])->lockForUpdate()->first();
if (in_array($table, ['reference_catalog_versions', 'economic_activities'], true)) {
if ($operation === 'publish' && $record->status === 'draft') {
DB::table($table)->where('id', $record->id)->update(['status' => 'published', 'updated_at' => now()]); $changed[] = $record->id;
} else { $reused[] = $record->id; }
} else {
$visibility = $operation === 'publish' ? 'public' : 'internal';
// Withdrawal touches only objects this importer created, never pre-existing public references.
$createdHere = $this->createdHere($record->id);
if ($operation === 'withdraw' && ! $createdHere) { $reused[] = $record->id; continue; }
if ($record->data_classification !== $visibility) { DB::table($table)->where('id', $record->id)->update(['data_classification' => $visibility, 'updated_at' => now()]); $changed[] = $record->id; }
else { $reused[] = $record->id; }
}
}
}
$result = ['operation' => $operation, 'lot' => 'lot-01', 'target' => $plan['target'], 'changed' => $changed, 'reused' => $reused,
'manifest_sha256' => EconomicReferenceCatalog::MANIFEST_SHA256, 'code_hash' => $plan['code_hash'], 'replayed' => false,
'publications' => $operation === 'publish' ? 'references only; no investment or original source publication' : 'none', 'business_records_changed' => 0,
'receipt' => $data['idempotency_key'], 'scan' => $scan ?? null];
if ($operation === 'import') { $result['previously_public'] = array_values(array_column(array_filter($plan['rows'], fn ($r) => $r['status'] === 'reuse' && in_array($r['current_visibility'], ['public', 'published'], true) && ! $this->createdHere($r['id'])), 'id')); }
DB::table('audit_events')->insert(['source_system' => EconomicReferenceCatalog::SOURCE, 'entity_type' => 'economic-reference-batch', 'entity_id' => $manifest['catalog']['id'],
'actor_user_id' => $actor->id, 'correlation_id' => $data['idempotency_key'], 'action' => $operation, 'data_classification' => 'restricted',
'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}