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

334 lines
26 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/** Additive installation and separate, explicit bootstrap. No identities are inferred. */
class PortalProfileInstallation
{
public const MIGRATION = '2026_10_10_040000_create_portal_profile_authorization';
public const TABLES = ['portal_profile_requests', 'portal_role_assignments', 'portal_superuser_pair', 'portal_security_approvals'];
public function __construct(private PortalProfileAccess $access) {}
public function fingerprint(): string
{
$files = [...PortalProfileAccess::fingerprintFiles(), __FILE__, config_path('portal_profiles.php'),
app_path('Services/TechnicalSupportProfileSchema.php'), app_path('Services/OpportunityTechnicalSupport.php'), app_path('Services/TechnicalIncidentHandling.php'), app_path('Services/OpportunityTechnicalIncidents.php'), app_path('Services/OpportunityWorkflowExecution.php'), app_path('Exceptions/OpportunityWorkflowUnavailable.php'), app_path('Http/Controllers/TechnicalSupportController.php'),
database_path('migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php'), resource_path('js/pages/profiles/support-installation.tsx'), resource_path('js/lib/profile-support-installation.ts'), resource_path('js/lib/page-preparation-guard.ts'), resource_path('js/lib/portal-api.ts'), resource_path('js/lib/operation-key.ts'), resource_path('js/pages/profiles/technical-support.tsx'), resource_path('js/pages/profiles/technical-incident.tsx'), resource_path('js/components/technical-incident-handling.tsx'), resource_path('js/components/opportunity-technical-incidents.tsx'), resource_path('js/lib/technical-incident-handling.ts'), resource_path('js/lib/technical-incident-labels.ts'),
app_path('Jobs/ScanDocumentForMalware.php'), app_path('Services/Malware/DocumentScanAttempts.php'), app_path('Services/Malware/DocumentScanDelivery.php'), app_path('Services/Malware/DocumentScanRetry.php'), app_path('Http/Controllers/DocumentMalwareController.php'), resource_path('js/pages/management/shared.tsx'), resource_path('js/pages/investment/zone-attachments.tsx'), app_path('Services/OpportunityScanDispatch.php'), app_path('Services/OpportunityTechnicalExecution.php'), app_path('Services/OpportunityResourceFileCleanup.php'), resource_path('js/components/technical-incident-notice.tsx'), app_path('Http/Controllers/PortalProfileInstallationController.php'), resource_path('js/pages/profiles/installation.tsx'), resource_path('js/pages/profiles/bootstrap-confirmation.tsx'), base_path('routes/web.php')];
return hash('sha256', json_encode(array_map(fn ($path) => hash_file('sha256', $path), array_unique($files)), JSON_THROW_ON_ERROR));
}
/** @return array{verified:bool,date:string|null,fingerprint:string|null} */
private function readiness(\stdClass $identity, string $code): array
{
$path = config('portal_profiles.proof_directory').'/readiness.json';
$bytes = is_file($path) && is_readable($path) ? file_get_contents($path) : false;
$decoded = is_string($bytes) ? json_decode($bytes, true) : null;
$proof = is_array($decoded) ? $decoded : [];
$date = is_string($proof['verified_at'] ?? null) ? $proof['verified_at'] : null;
$timestamp = $date !== null ? strtotime($date) : false;
$target = is_array($proof['target'] ?? null) ? $proof['target'] : [];
$port = $target['port'] ?? null;
$dump = $proof['dump_path'] ?? null;
$files = $proof['files_path'] ?? null;
$verified = $timestamp !== false && $timestamp >= time() - 86400 && $timestamp <= time() && ($proof['code_hash'] ?? null) === $code && ($target['database'] ?? null) === $identity->database
&& (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port === (int) $identity->port
&& ($proof['restore_verified'] ?? null) === true && ($proof['files_verified'] ?? null) === true && ($proof['compatibility_verified'] ?? null) === true && ($proof['qa_passed'] ?? null) === true
&& is_string($dump) && is_file($dump) && is_readable($dump) && hash_file('sha256', $dump) === ($proof['dump_sha256'] ?? null)
&& is_string($files) && is_file($files) && is_readable($files) && hash_file('sha256', $files) === ($proof['files_sha256'] ?? null);
return ['verified' => $verified, 'date' => $date, 'fingerprint' => is_string($bytes) ? hash('sha256', $bytes) : null];
}
/** @return array<string,mixed> */
public function supportPreview(User $actor): array
{
$this->authorize($actor);
$base = $this->preview($actor);
$schema = app(TechnicalSupportProfileSchema::class)->inspect();
$dependencies = ['profiles_installed' => $base['installed'], 'profiles_consistent' => $base['consistent'], ...$base['dependencies']];
$readiness = $this->readiness($base['target'], $base['code_hash']);
$action = $schema['installed'] ? 'none' : ($schema['repairable'] ? 'repair_known_base' : ($schema['state'] === 'base' && ! $schema['ledger'] ? 'install' : 'blocked'));
$plan = ['target' => $base['target'], 'actorId' => $actor->id, 'code_hash' => $base['code_hash'], 'migration' => TechnicalSupportProfileSchema::MIGRATION, 'schema' => $schema,
'dependencies' => $dependencies, 'installed' => $schema['installed'], 'consistent' => $schema['consistent'], 'repairable' => $schema['repairable'], 'action' => $action,
'ready' => ! in_array(false, $dependencies, true) && ($schema['consistent'] || $schema['repairable']) && $readiness['verified'] && $action !== 'blocked',
'backupDate' => $readiness['date'], 'readiness_fingerprint' => $readiness['fingerprint'],
'changes' => ['Acrescenta Suporte técnico aos papéis reconhecidos. Atribuir o perfil é uma operação posterior na Gestão de perfis.', 'Conserva contas, atribuições, oportunidades, incidentes e histórico.', 'Aplica apenas a actualização de perfis indicada neste destino.'],
'rollback' => 'A reversão da actualização exige preservar atribuições de Suporte técnico, incluindo as históricas; o downgrade recusa removê-las.'];
$commands = [];
foreach (DB::table('audit_events')->where('source_system', 'portal-profile-installation')->where('action', 'technical-support-install')->where('actor_user_id', $actor->id)->get(['correlation_id', 'after_data']) as $event) {
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR);
$commands[] = ['key' => $event->correlation_id, 'code_hash' => $data['code_hash'] ?? null];
}
$plan['hash'] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR));
$plan['commands'] = $commands;
$plan['mfa_confirmed'] = $this->access->mfa($actor);
return $plan;
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function applySupport(User $actor, array $input): array
{
$this->authorize($actor, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate();
$data['idempotency_key'] = strtolower($data['idempotency_key']);
$data['confirm'] = true;
$data['reason'] = trim($data['reason']);
Validator::make(['reason' => $data['reason']], ['reason' => 'required|string|min:20|max:1000'])->validate();
return $this->receipt($actor, 'technical-support-install', $data, function () use ($actor, $data) {
$plan = $this->supportPreview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'O plano mudou. Consulte e compare a prévia actual.');
abort_unless($plan['consistent'] || $plan['repairable'], 409, 'A estrutura dos perfis diverge da versão reconhecida. Preserve os dados e reconcilie a divergência.');
abort_unless(! in_array(false, $plan['dependencies'], true), 422, 'Instale e confira primeiro a base dos perfis e as suas dependências.');
$identity = ['actor_id' => $actor->id, 'command_key' => $data['idempotency_key'], 'target' => $plan['target']];
if ($plan['installed']) {
return [...$identity, 'installed' => true, 'changed' => false, 'migration' => TechnicalSupportProfileSchema::MIGRATION, 'code_hash' => $plan['code_hash'], 'mode' => 'already_installed', 'flags_changed' => false, 'privileges_granted' => false];
}
abort_unless($plan['ready'] && in_array($plan['action'], ['install', 'repair_known_base'], true), 422, 'Provas actuais de backup, restauro, ficheiros e compatibilidade são necessárias.');
$previousFlag = config('portal_profiles.authenticated_installation');
config(['portal_profiles.authenticated_installation' => true]);
try {
if ($plan['action'] === 'repair_known_base') {
(require database_path('migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php'))->up();
} else {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.TechnicalSupportProfileSchema::MIGRATION.'.php', '--force' => true]) === 0, 500, 'A actualização dos perfis falhou.');
}
} finally {
config(['portal_profiles.authenticated_installation' => $previousFlag]);
}
$after = app(TechnicalSupportProfileSchema::class)->inspect();
abort_unless($after['installed'] && $after['consistent'], 500, 'A verificação da actualização falhou; a transacção será revertida.');
return [...$identity, 'installed' => true, 'changed' => true, 'migration' => TechnicalSupportProfileSchema::MIGRATION, 'code_hash' => $plan['code_hash'], 'mode' => $plan['action'], 'flags_changed' => false, 'privileges_granted' => false];
});
}
public function designatedBootstrap(User $actor): bool
{
$emails = config('portal_profiles.bootstrap_designated_emails', []);
if (! config('portal.homologation_only', false) || count(array_unique($emails)) !== 2
|| ! in_array(strtolower(trim($actor->email)), $emails, true)) {
return false;
}
$target = config('portal_profiles.bootstrap_designated_database', '');
return $target !== '' && DB::selectOne('SELECT current_database() AS database')->database === $target;
}
public function authorize(User $actor, bool $write = false, bool $bootstrapOnly = false): void
{
abort_unless($actor->hasVerifiedEmail(), 403);
if (Schema::hasTable('portal_role_assignments')) {
abort_if(DB::table('portal_role_assignments')->where('user_id', $actor->id)->where('role', 'auditor')->whereNull('revoked_at')
->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(), 403, 'A restrição do Auditor prevalece.');
}
$pair = Schema::hasTable('portal_superuser_pair') ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null;
abort_unless($pair ? in_array($actor->id, [(int) $pair->first_user_id, (int) $pair->second_user_id], true) : ($actor->can('manage-portal') || ($bootstrapOnly && $this->access->installed() && $this->designatedBootstrap($actor))), 403, 'Gestor de instalação ou titular designado necessário.');
if ($write) {
abort_unless($this->access->mfa($actor), 403, 'Conclua MFA nesta sessão; configuração de MFA isoladamente não autoriza a operação.');
}
}
/** @return array<string,mixed> */
public function preview(User $actor): array
{
$this->authorize($actor, false, true);
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$ledger = DB::table('migrations')->where('migration', self::MIGRATION)->exists();
$schema = [];
foreach (self::TABLES as $table) {
$schema[$table] = Schema::hasTable($table);
}
foreach (['users' => ['portal_access_revision'], 'opportunities' => ['owner_user_id', 'organization_id', 'workflow_context']] as $table => $columns) {
foreach ($columns as $column) {
$schema[$table.'.'.$column] = Schema::hasColumn($table, $column);
}
}
$all = ! in_array(false, $schema, true);
$none = ! in_array(true, $schema, true);
$consistent = ($ledger && $all) || (! $ledger && $none);
$installed = $ledger && $all;
$dependencies = [
'management_020000' => DB::table('migrations')->where('migration', '2026_10_09_020000_extend_opportunity_management')->exists() && Schema::hasColumn('opportunities', 'management_draft'),
'resources_030000' => DB::table('migrations')->where('migration', '2026_10_09_030000_extend_opportunity_resources')->exists()
&& Schema::hasTable('opportunity_documents') && Schema::hasTable('opportunity_images') && Schema::hasTable('opportunity_location_drafts') && Schema::hasColumn('documents', 'storage_encryption'),
'existing_identity_organization_audit' => Schema::hasTable('users') && Schema::hasTable('organizations') && Schema::hasTable('memberships') && Schema::hasTable('audit_events'),
];
$code = $this->fingerprint();
$readiness = $this->readiness($identity, $code);
$evidence = $readiness['verified'];
$ready = $consistent && ! in_array(false, $dependencies, true) && $evidence;
$pair = $installed ? DB::table('portal_superuser_pair')->where('id', 1)->first() : null;
$bootstrap = ['initialized' => $pair !== null, 'holders' => $pair ? [(int) $pair->first_user_id, (int) $pair->second_user_id] : [], 'version' => $pair?->version,
'rule' => 'O primeiro titular apresenta o pedido com MFA e autorização de instalação; o segundo confirma numa operação separada com MFA. Sem autoaprovação, terceira pessoa obrigatória ou escolha automática.'];
$pending = $installed && ! $pair ? DB::table('portal_security_approvals')->where('kind', 'bootstrap')->whereNull('consumed_at')->where('expires_at', '>', now())
->where(fn ($q) => $q->where('requested_by', $actor->id)->orWhere('subject_user_id', $actor->id))->first() : null;
$bootstrap['pending'] = $pending ? ['approval_id' => $pending->id, 'confirmation_url' => '/management/profile-install/bootstrap/'.$pending->id, 'expires_at' => $pending->expires_at] : null;
$plan = ['target' => $identity, 'code_hash' => $code, 'installed' => $installed, 'consistent' => $consistent, 'ledger' => $ledger, 'schema' => $schema,
'dependencies' => $dependencies, 'ready' => $ready, 'backupDate' => $readiness['date'], 'readiness_fingerprint' => $readiness['fingerprint'], 'bootstrap' => $bootstrap,
'path_190000_installed' => DB::table('migrations')->where('migration', '2026_10_09_190000_prepare_investor_path')->exists(),
'technicalSupportUpdateUrl' => '/management/profile-install/technical-support',
'migration' => self::MIGRATION, 'changes' => ['Quatro tabelas, uma coluna em users e três em opportunities; zero contas e zero atribuições automáticas.',
'030000 precede esta ação; 190000 continua etapa posterior do percurso, sem dependência DDL desta migração.', 'Nenhuma flag ativada, dado publicado, titular escolhido ou privilégio legado removido.'],
'rollback' => 'DDL e ledger são atómicos em PostgreSQL. Se vazio, down deve remover todas as colunas adicionadas; se houver uso/histórico, preservar e restaurar backup verificado, sem rollback destrutivo.'];
$plan['hash'] = hash('sha256', json_encode($plan, JSON_THROW_ON_ERROR));
return $plan;
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate();
return $this->receipt($actor, 'install', $data, function () use ($actor, $data) {
$p = $this->preview($actor);
abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado; consulte a pré-visualização.');
abort_unless($p['consistent'], 409, 'Ledger/schema incoerentes; recuperar antes de instalar.');
if ($p['installed']) {
return ['installed' => true, 'changed' => false, 'migration' => self::MIGRATION, 'bootstrap_required' => ! $p['bootstrap']['initialized'], 'flags_changed' => false];
}
abort_unless($p['ready'], 422, 'Dependências e provas atuais de backup/restauro/QA são necessárias.');
config(['portal_profiles.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.self::MIGRATION.'.php', '--force' => true]) === 0, 500, 'A instalação falhou; a transação é revertida.');
} finally {
config(['portal_profiles.authenticated_installation' => false]);
}
$after = $this->preview($actor);
abort_unless($after['installed'] && $after['consistent'], 500, 'Verificação pós-instalação falhou; reverter transação.');
return ['installed' => true, 'changed' => true, 'migration' => self::MIGRATION, 'bootstrap_required' => true, 'flags_changed' => false];
});
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function bootstrap(User $actor, array $input): array
{
$this->authorize($actor, true, true);
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid',
'first_user_id' => 'required|integer|exists:users,id', 'second_user_id' => 'required|integer|different:first_user_id|exists:users,id', 'reason' => 'required|string|min:20|max:1000'])->validate();
abort_unless((int) $data['first_user_id'] === $actor->id, 403, 'O primeiro titular deve apresentar o seu próprio pedido; não é permitido inicializar terceiros.');
return $this->receipt($actor, 'bootstrap-request', $data, function () use ($actor, $data) {
$p = $this->preview($actor);
abort_unless(hash_equals($p['hash'], $data['hash']), 409, 'Plano alterado.');
abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Schema consistente instalado e dupla não inicializada necessários.');
$second = User::whereKey($data['second_user_id'])->firstOrFail();
if ($this->designatedBootstrap($actor)) {
abort_unless($this->designatedBootstrap($second), 403, 'Use os dois titulares expressamente designados para este destino.');
}
foreach ([$actor, $second] as $user) {
app(PortalSuperusers::class)->validateBootstrapHolder($user);
}
abort_if(DB::table('portal_security_approvals')->where('kind', 'bootstrap')->whereNull('consumed_at')->where('expires_at', '>', now())->exists(), 409, 'Já existe pedido pendente. Recupere-o; não duplique a cerimónia.');
$id = (string) Str::uuid();
DB::table('portal_security_approvals')->insert(['id' => $id, 'kind' => 'bootstrap', 'requested_by' => $actor->id, 'subject_user_id' => $second->id,
'scope' => json_encode(['first_user_id' => $actor->id, 'second_user_id' => $second->id, 'code_hash' => $this->fingerprint(),
'target' => $p['target'], 'preview_hash' => $p['hash']], JSON_THROW_ON_ERROR), 'reason' => $data['reason'],
'expires_at' => now()->addMinutes(15), 'created_at' => now(), 'updated_at' => now()]);
return ['initialized' => false, 'pending' => true, 'approval_id' => $id, 'confirmation_url' => '/management/profile-install/bootstrap/'.$id,
'holders' => [$actor->id, $second->id], 'expires_in_seconds' => 900, 'flags_changed' => false, 'privileges_granted' => false];
});
}
/** Purpose-specific view; designation grants no general management access.
* @return array<array-key, mixed>
*/
public function bootstrapPreview(User $actor, string $id): array
{
abort_unless($actor->hasVerifiedEmail(), 403);
$row = DB::table('portal_security_approvals')->where('id', $id)->where('kind', 'bootstrap')->first();
abort_unless($row !== null, 404);
if (! (in_array($actor->id, [(int) $row->requested_by, (int) $row->subject_user_id], true))) {
abort(403, 'Apenas os dois titulares designados consultam este pedido.');
}
app(PortalSuperusers::class)->validateBootstrapHolder($actor);
$scope = json_decode($row->scope, true, 512, JSON_THROW_ON_ERROR);
$identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$current = $scope['code_hash'] === $this->fingerprint() && $scope['target']['database'] === $identity->database && (int) $scope['target']['port'] === (int) $identity->port;
$view = ['approval_id' => $id, 'requested_by' => (int) $row->requested_by, 'second_user_id' => (int) $row->subject_user_id,
'reason' => $row->reason, 'expires_at' => $row->expires_at, 'consumed' => $row->consumed_at !== null, 'code_current' => $current,
'expired' => strtotime($row->expires_at) <= time(), 'target' => $identity,
'can_confirm' => $actor->id === (int) $row->subject_user_id && $row->consumed_at === null && strtotime($row->expires_at) > time() && $current,
'mfa_confirmed_in_session' => $this->access->mfa($actor)];
$view['hash'] = hash('sha256', json_encode([$id, $row->requested_by, $row->subject_user_id, $row->scope, $row->reason, $row->expires_at], JSON_THROW_ON_ERROR));
return $view;
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function confirmBootstrap(User $actor, string $id, array $input): array
{
$view = $this->bootstrapPreview($actor, $id);
abort_unless($actor->id === $view['second_user_id'] && $actor->id !== $view['requested_by'], 403, 'O pedido não pode ser autoaprovado.');
abort_unless($this->access->mfa($actor), 403, 'Conclua MFA nesta sessão.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted', 'idempotency_key' => 'required|uuid', 'reason' => 'required|string|min:20|max:1000'])->validate();
$data['approval_id'] = $id;
return $this->receipt($actor, 'bootstrap-confirm', $data, function () use ($actor, $id, $data) {
$row = DB::table('portal_security_approvals')->where('id', $id)->lockForUpdate()->first();
$view = $this->bootstrapPreview($actor, $id);
abort_unless(hash_equals($view['hash'], $data['hash']), 409, 'Pedido alterado; consulte novamente.');
abort_unless($view['can_confirm'], 409, 'Pedido expirado, já consumido ou revisão/destino alterados.');
$first = User::whereKey($row->requested_by)->firstOrFail();
$this->authorize($first, false, true);
$p = $this->preview($first);
abort_unless($p['installed'] && $p['consistent'] && ! in_array(false, $p['dependencies'], true) && ! $p['bootstrap']['initialized'], 409, 'Dependências ou dupla alteradas.');
app(PortalSuperusers::class)->validateBootstrapHolder($first);
DB::table('portal_security_approvals')->where('id', $id)->update(['approved_by' => $actor->id, 'updated_at' => now()]);
config(['portal_profiles.bootstrap_authorized' => true]);
try {
app(PortalSuperusers::class)->initialize($first, $actor, $actor, $id);
} finally {
config(['portal_profiles.bootstrap_authorized' => false]);
}
return ['initialized' => true, 'approval_id' => $id, 'holders' => [$first->id, $actor->id], 'version' => 1, 'flags_changed' => false];
});
}
/** @param array<string,mixed> $data
* @param callable():array<string,mixed> $operation
* @return array<string,mixed> */
private function receipt(User $actor, string $scope, array $data, callable $operation): array
{
return DB::transaction(function () use ($actor, $scope, $data, $operation) {
DB::select('SELECT pg_advisory_xact_lock(101026, 40000)');
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$previous = DB::table('audit_events')->where('source_system', 'portal-profile-installation')->where('action', $scope)->where('actor_user_id', $actor->id)->where('correlation_id', $data['idempotency_key'])->first();
if ($previous) {
$receipt = json_decode($previous->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless(hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave de repetição reutilizada com outro conteúdo.');
return $receipt['result'];
}
$result = $operation();
DB::table('audit_events')->insert(['source_system' => 'portal-profile-installation', 'entity_type' => 'installation', 'actor_user_id' => $actor->id, 'action' => $scope,
'correlation_id' => $data['idempotency_key'], 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'reason' => $data['reason'], 'result' => $result, 'code_hash' => $this->fingerprint()], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}