116 lines
8.0 KiB
PHP
116 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
class PortalSuperusers
|
|
{
|
|
public function __construct(private PortalProfileAccess $access, private PortalProfileAdministration $administration) {}
|
|
|
|
public function initialize(User $first, User $second, User $approver): void
|
|
{
|
|
// Bootstrap requires a separately authorized ceremony; never infer real holders.
|
|
abort_unless(config('portal_profiles.bootstrap_authorized') === true, 403);
|
|
abort_unless(count(array_unique([$first->id, $second->id, $approver->id])) === 3, 422);
|
|
foreach ([$first, $second, $approver] as $user) {
|
|
abort_unless($user->hasVerifiedEmail() && $user->two_factor_secret && $user->two_factor_confirmed_at, 422, 'Contas confirmadas com MFA necessário.');
|
|
try {
|
|
$secret = Crypt::decryptString($user->two_factor_secret);
|
|
} catch (DecryptException) {
|
|
abort(422, 'Configuração MFA incompatível com este ambiente. Recuperar a conta pelo procedimento existente antes da inicialização.');
|
|
}
|
|
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 de titular nesta cerimónia.');
|
|
}
|
|
abort_unless($this->access->mfa($approver), 403);
|
|
DB::transaction(function () use ($first, $second, $approver) {
|
|
DB::select('SELECT pg_advisory_xact_lock(101026, 40001)');
|
|
$existing = DB::table('portal_superuser_pair')->where('id', 1)->first();
|
|
if ($existing) {
|
|
abort_unless((int) $existing->first_user_id === $first->id && (int) $existing->second_user_id === $second->id, 409, 'Inicialização já realizada. Use substituição atómica.');
|
|
|
|
return;
|
|
}
|
|
DB::table('portal_superuser_pair')->insert(['id' => 1, 'first_user_id' => $first->id, 'second_user_id' => $second->id, 'approved_by' => $approver->id, 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->access->audit($approver, 'superuser_pair_initialized', ['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_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, ['create', 'edit', 'validate', 'publish', 'archive'], true) && mb_strlen($reason) >= 20 && mb_strlen($reason) <= 1000, 422);
|
|
$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;
|
|
}
|
|
}
|