Files
Investidor-V2/app/Services/PortalSuperusers.php
nala 287e991486
tests / ci (push) Waiting to run
oportunidades 7
2026-10-13 11:40:29 +02:00

138 lines
9.6 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class PortalSuperusers
{
public function __construct(private PortalProfileAccess $access, private PortalProfileAdministration $administration) {}
private function mfaSecret(User $user): string
{
// Normal Fortify enrollment serializes the string; older QA fixtures used encryptString.
// Read either existing envelope without re-encryption, unserializing objects or changing keys.
$plain = Fortify::currentEncrypter()->decrypt($user->two_factor_secret, false);
abort_unless(is_string($plain), 422, 'Configuração MFA inválida.');
if (preg_match('/^s:[0-9]+:"([A-Z2-7]+=*)";$/D', $plain, $matches)
&& serialize($matches[1]) === $plain) {
return $matches[1];
}
return $plain;
}
public function validateBootstrapHolder(User $user): void
{
abort_unless($user->hasVerifiedEmail() && $user->two_factor_secret && $user->two_factor_confirmed_at, 422, 'Conta confirmada com MFA necessário.');
try {
$secret = $this->mfaSecret($user);
} catch (DecryptException) {
abort(422, 'Configuração MFA incompatível com este ambiente. Recuperar pelo procedimento existente.');
}
abort_unless(strlen($secret) >= 16 && preg_match('/^[A-Z2-7]+=*$/', $secret), 422, 'Configuração MFA inválida.');
abort_if(DB::table('portal_role_assignments')->where('user_id', $user->id)->where('role', 'auditor')->whereNull('revoked_at')
->where('starts_at', '<=', now())->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))->exists(), 422, 'A restrição do Auditor impede a designação nesta cerimónia.');
}
public function initialize(User $first, User $second, User $confirmer, string $approvalId): void
{
abort_unless(config('portal_profiles.bootstrap_authorized') === true, 403);
abort_unless($first->id !== $second->id && $confirmer->id === $second->id, 403, 'Confirmação pelo segundo titular, distinta do pedido.');
foreach ([$first, $second] as $user) $this->validateBootstrapHolder($user);
abort_unless($this->access->mfa($confirmer), 403);
DB::transaction(function () use ($first, $second, $confirmer, $approvalId) {
DB::select('SELECT pg_advisory_xact_lock(101026, 40001)');
$approval = DB::table('portal_security_approvals')->where('id', $approvalId)->lockForUpdate()->first();
abort_unless($approval && $approval->kind === 'bootstrap' && (int) $approval->requested_by === $first->id
&& (int) $approval->subject_user_id === $second->id && (int) $approval->approved_by === $confirmer->id
&& $approval->consumed_at === null && strtotime($approval->expires_at) > time(), 409, 'Pedido cruzado inválido, expirado ou já consumido.');
abort_if(DB::table('portal_superuser_pair')->where('id', 1)->exists(), 409, 'Dupla existente; use substituição atómica.');
DB::table('portal_superuser_pair')->insert(['id' => 1, 'first_user_id' => $first->id, 'second_user_id' => $second->id, 'approved_by' => $confirmer->id, 'created_at' => now(), 'updated_at' => now()]);
DB::table('portal_security_approvals')->where('id', $approvalId)->update(['consumed_at' => now(), 'updated_at' => now()]);
$this->access->audit($confirmer, 'superuser_pair_initialized', ['approval_id' => $approvalId, 'requested_by' => $first->id, 'holders' => [$first->id, $second->id], 'version' => 1]);
});
}
public function requestReplacement(User $actor, int $oldId, int $newId, string $reason): string
{
$this->administration->administrative($actor);
abort_unless(mb_strlen($reason) >= 20 && mb_strlen($reason) <= 1000 && $oldId !== $newId, 422);
return DB::transaction(function () use ($actor, $oldId, $newId, $reason) {
$pair = DB::table('portal_superuser_pair')->where('id', 1)->lockForUpdate()->first();
$holders = [(int) $pair->first_user_id, (int) $pair->second_user_id];
abort_unless(in_array($oldId, $holders, true) && ! in_array($newId, $holders, true), 422);
$candidate = User::findOrFail($newId);
abort_unless($candidate->hasVerifiedEmail() && $candidate->two_factor_secret && $candidate->two_factor_confirmed_at, 422);
$id = (string) Str::uuid();
DB::table('portal_security_approvals')->insert(['id' => $id, 'kind' => 'replacement', 'requested_by' => $actor->id, 'subject_user_id' => $newId,
'scope' => json_encode(['old_user_id' => $oldId, 'pair_version' => (int) $pair->version], JSON_THROW_ON_ERROR), 'reason' => $reason,
'expires_at' => now()->addMinutes(15), 'created_at' => now(), 'updated_at' => now()]);
$this->access->audit($actor, 'superuser_replacement_requested', ['approval_id' => $id, 'old_user_id' => $oldId, 'new_user_id' => $newId]);
return $id;
});
}
public function approve(User $actor, string $id): void
{
$this->administration->administrative($actor);
DB::transaction(function () use ($actor, $id) {
$row = DB::table('portal_security_approvals')->where('id', $id)->lockForUpdate()->first();
abort_unless($row !== null, 404);
abort_unless(in_array($row->kind, ['replacement', 'elevation'], true), 403, 'Use a confirmação específica da inicialização cruzada.');
abort_if((int) $row->requested_by === $actor->id || (int) $row->subject_user_id === $actor->id, 403, 'Aprovação independente necessária.');
abort_unless($row->consumed_at === null && strtotime($row->expires_at) > time() && $row->approved_by === null, 409);
DB::table('portal_security_approvals')->where('id', $id)->update(['approved_by' => $actor->id, 'updated_at' => now()]);
$this->access->audit($actor, 'security_operation_approved', ['approval_id' => $id, 'kind' => $row->kind]);
});
}
public function applyReplacement(User $actor, string $id): void
{
$this->administration->administrative($actor);
DB::transaction(function () use ($actor, $id) {
DB::select('SELECT pg_advisory_xact_lock(101026, 40001)');
$pair = DB::table('portal_superuser_pair')->where('id', 1)->lockForUpdate()->first();
$row = DB::table('portal_security_approvals')->where('id', $id)->lockForUpdate()->first();
abort_unless($row && $row->kind === 'replacement' && (int) $row->requested_by === $actor->id, 403);
abort_unless($row->approved_by !== null && $row->consumed_at === null && strtotime($row->expires_at) > time(), 409);
$scope = json_decode($row->scope, true, 512, JSON_THROW_ON_ERROR);
$holders = [(int) $pair->first_user_id, (int) $pair->second_user_id];
abort_unless((int) $pair->version === $scope['pair_version'] && in_array((int) $row->approved_by, $holders, true)
&& in_array($scope['old_user_id'], $holders, true) && ! in_array((int) $row->subject_user_id, $holders, true), 409, 'Dupla ou aprovação mudou.');
$candidate = User::whereKey($row->subject_user_id)->firstOrFail();
abort_unless($candidate->hasVerifiedEmail() && $candidate->two_factor_secret && $candidate->two_factor_confirmed_at, 422);
$column = (int) $pair->first_user_id === $scope['old_user_id'] ? 'first_user_id' : 'second_user_id';
// A single UPDATE preserves two distinct holders at every observable state.
DB::table('portal_superuser_pair')->where('id', 1)->update([$column => $candidate->id, 'version' => $pair->version + 1, 'approved_by' => $row->approved_by, 'updated_at' => now()]);
DB::table('portal_security_approvals')->where('id', $id)->update(['consumed_at' => now(), 'updated_at' => now()]);
$this->access->revokeSessions(User::findOrFail($scope['old_user_id']));
$this->access->audit($actor, 'superuser_replaced', ['approval_id' => $id, 'before' => $holders, 'after' => DB::table('portal_superuser_pair')->where('id', 1)->first(), 'alert' => 'Substituição de titular: verificar acessos e recuperação.']);
});
}
public function requestElevation(User $actor, string $action, ?int $recordId, string $reason): string
{
$this->administration->administrative($actor);
abort_unless(in_array($action, ['view', 'create', 'edit', 'validate', 'publish', 'archive'], true) && mb_strlen($reason) >= 20 && mb_strlen($reason) <= 1000, 422);
if ($action === 'view') {
$record = $recordId === null ? null : \App\Models\Opportunity::find($recordId);
abort_unless($record && $this->access->hasOperationalReadScope($actor, $record) && ! $this->access->has($actor, 'auditor'), 403, 'A consulta exige perfil operacional com âmbito válido; a elevação não o substitui.');
}
$id = (string) Str::uuid();
DB::table('portal_security_approvals')->insert(['id' => $id, 'kind' => 'elevation', 'requested_by' => $actor->id, 'subject_user_id' => $actor->id,
'scope' => json_encode(['action' => $action, 'record_id' => $recordId], JSON_THROW_ON_ERROR), 'reason' => $reason,
'expires_at' => now()->addMinutes(15), 'created_at' => now(), 'updated_at' => now()]);
$this->access->audit($actor, 'elevation_requested', ['approval_id' => $id, 'action' => $action, 'record_id' => $recordId, 'reason' => $reason]);
return $id;
}
}