167 lines
11 KiB
PHP
167 lines
11 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;
|
|
|
|
/** 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('Http/Controllers/PortalProfileInstallationController.php'), resource_path('js/pages/profiles/installation.tsx')];
|
|
|
|
return hash('sha256', json_encode(array_map(fn ($path) => hash_file('sha256', $path), array_unique($files)), JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
public function authorize(User $actor, bool $write = 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'), 403, 'Gestor de instalação ou titular da dupla 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);
|
|
$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();
|
|
$proofPath = config('portal_profiles.proof_directory').'/readiness.json';
|
|
$proofBytes = is_file($proofPath) ? file_get_contents($proofPath) : false;
|
|
$proof = is_string($proofBytes) ? json_decode($proofBytes, true) : [];
|
|
$proof = is_array($proof) ? $proof : [];
|
|
$fresh = isset($proof['verified_at']) && strtotime($proof['verified_at']) >= time() - 86400;
|
|
$evidence = $fresh && ($proof['code_hash'] ?? '') === $code && ($proof['target']['database'] ?? '') === $identity->database
|
|
&& (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && ($proof['restore_verified'] ?? false) && ($proof['files_verified'] ?? false)
|
|
&& ($proof['compatibility_verified'] ?? false) && ($proof['qa_passed'] ?? false)
|
|
&& is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['dump_sha256'] ?? null)
|
|
&& is_file($proof['files_path'] ?? '') && hash_file('sha256', $proof['files_path']) === ($proof['files_sha256'] ?? null);
|
|
$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' => 'Escolher explicitamente duas contas confirmadas com MFA; um terceiro gestor com MFA aprova. Sem escolha automática, sem alteração de papéis legados.'];
|
|
$plan = ['target' => $identity, 'code_hash' => $code, 'installed' => $installed, 'consistent' => $consistent, 'ledger' => $ledger, 'schema' => $schema,
|
|
'dependencies' => $dependencies, 'ready' => $ready, 'backupDate' => $proof['verified_at'] ?? null, 'bootstrap' => $bootstrap,
|
|
'path_190000_installed' => DB::table('migrations')->where('migration', '2026_10_09_190000_prepare_investor_path')->exists(),
|
|
'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);
|
|
$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();
|
|
|
|
return $this->receipt($actor, 'bootstrap', $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['bootstrap']['initialized'], 409, 'Inicialização exige schema instalado e dupla ainda não designada. Use substituição para uma dupla existente.');
|
|
config(['portal_profiles.bootstrap_authorized' => true]);
|
|
try {
|
|
app(PortalSuperusers::class)->initialize(User::query()->whereKey($data['first_user_id'])->firstOrFail(), User::query()->whereKey($data['second_user_id'])->firstOrFail(), $actor);
|
|
} finally {
|
|
config(['portal_profiles.bootstrap_authorized' => false]);
|
|
}
|
|
|
|
return ['initialized' => true, 'holders' => [$data['first_user_id'], $data['second_user_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;
|
|
});
|
|
}
|
|
}
|