Files
Investidor-V2/app/Services/EconomicReferenceInstallation.php
T

423 lines
24 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
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'];
/**
* @return array<array-key, mixed>
*/
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<array-key, mixed>
*/
private function target(): array
{
return (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
}
/**
* @param array<array-key, mixed> $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<array-key, mixed> $target
* @return array<array-key, mixed>
*/
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<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
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<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
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<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
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<array-key, mixed>
*/
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<array-key, mixed> $manifest
* @return array<array-key, mixed>
*/
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<array-key, mixed> $input
* @return array<array-key, mixed>
*/
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;
});
}
}