*/ 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'); } /** * @return array */ private function target(): array { return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); } /** * @param array $target */ 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() && app(ScopedPortalAdministration::class)->administrative($actor), 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.'); } /** * @param array $target * @return array */ 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(CheckedFile::read($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'] ?? '', CheckedFile::sha256($proof['dump_path'])) && hash_equals($proof['files_sha256'] ?? '', CheckedFile::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. * @param array $manifest * @return array */ 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'], CheckedFile::sha256($path)); $sources[] = ['name' => $entry['name'], 'sha256' => $entry['sha256'], 'bytes' => $entry['bytes'], 'ready' => (bool) $matches]; } return $sources; } /** * @param array $manifest * @return array */ 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(); } /** * @param array $manifest * @return array */ 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; } /** * @return array */ 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(CheckedFile::read($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]; } /** * @param array $manifest * @return array */ private function scan(array $manifest): array { $directory = realpath($this->directory()); $records = []; abort_unless(is_string($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'], CheckedFile::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, CheckedFile::size($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(CheckedFile::matches($file['path'], $file['sha256']) && CheckedFile::matches($path, $file['sha256']), 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; } /** * @param array $input * @return array */ 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_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; }); } }