145 lines
11 KiB
PHP
145 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class PortalProfileAdministration
|
|
{
|
|
public function __construct(private PortalProfileAccess $access) {}
|
|
|
|
public function administrative(User $actor): void
|
|
{
|
|
abort_unless($this->access->superuser($actor) && $this->access->mfa($actor), 403, 'Superutilizador com MFA recente necessário.');
|
|
abort_if($this->access->has($actor, 'auditor'), 403, 'A restrição de leitura do Auditor prevalece.');
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function grant(User $actor, array $input): string
|
|
{
|
|
$this->administrative($actor);
|
|
$data = Validator::make($input, ['user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher', 'auditor'])],
|
|
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
|
|
'responsibilities' => 'sometimes|array', 'responsibilities.*' => ['required', Rule::in(['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer', 'publication_coordinator'])],
|
|
'expires_at' => 'nullable|date|after:now', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
|
abort_if((int) $data['user_id'] === $actor->id, 403, 'Não pode aprovar a sua atribuição.');
|
|
abort_if($data['role'] === 'promoter', 422, 'Promotor exige pedido e revisão documental independente.');
|
|
$responsibilities = match ($data['role']) {
|
|
'validator' => ['validation_coordinator', 'technical_reviewer', 'legal_reviewer', 'appeal_reviewer'],
|
|
'publisher' => ['publication_coordinator'],
|
|
default => [],
|
|
};
|
|
abort_unless(array_diff($data['responsibilities'] ?? [], $responsibilities) === [], 422, 'Responsabilidade incompatível com o papel.');
|
|
$subject = User::whereKey($data['user_id'])->firstOrFail();
|
|
abort_unless($subject->hasVerifiedEmail(), 422);
|
|
if (in_array($data['role'], ['validator', 'publisher'], true)) {
|
|
abort_unless($subject->two_factor_secret !== null && $subject->two_factor_confirmed_at !== null, 422, 'Configure e confirme MFA antes de atribuir este papel.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($actor, $data) {
|
|
$id = (string) Str::uuid();
|
|
$this->insertGrant($id, $actor, $data);
|
|
$this->access->audit($actor, 'role_granted', ['assignment_id' => $id, 'subject_user_id' => $data['user_id'], 'role' => $data['role'], 'reason' => $data['reason']]);
|
|
|
|
return $id;
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $data */
|
|
public function insertGrant(string $id, User $approver, array $data): void
|
|
{
|
|
DB::table('portal_role_assignments')->insert(['id' => $id, 'user_id' => $data['user_id'], 'role' => $data['role'], 'organization_id' => $data['organization_id'] ?? null,
|
|
'opportunity_types' => json_encode(array_values(array_unique($data['opportunity_types'])), JSON_THROW_ON_ERROR), 'responsibilities' => json_encode($data['responsibilities'] ?? [], JSON_THROW_ON_ERROR),
|
|
'source' => $data['source'] ?? 'independent-administration', 'request_id' => $data['request_id'] ?? null, 'approved_by' => $approver->id,
|
|
'starts_at' => now(), 'expires_at' => $data['expires_at'] ?? null, 'created_at' => now(), 'updated_at' => now()]);
|
|
}
|
|
|
|
public function revoke(User $actor, string $id, string $reason): void
|
|
{
|
|
$this->administrative($actor);
|
|
Validator::make(['reason' => $reason], ['reason' => 'required|string|min:20|max:1000'])->validate();
|
|
DB::transaction(function () use ($actor, $id, $reason) {
|
|
$row = DB::table('portal_role_assignments')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($row !== null, 404);
|
|
if ($row->revoked_at !== null) {
|
|
return;
|
|
}
|
|
DB::table('portal_role_assignments')->where('id', $id)->update(['revoked_at' => now(), 'updated_at' => now()]);
|
|
$this->access->revokeSessions(User::whereKey($row->user_id)->firstOrFail());
|
|
$this->access->audit($actor, 'role_revoked', ['assignment_id' => $id, 'subject_user_id' => $row->user_id, 'reason' => $reason]);
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function request(User $actor, array $input): string
|
|
{
|
|
abort_unless($this->access->available() && $actor->hasVerifiedEmail(), 403);
|
|
$data = Validator::make($input, ['creation_key' => 'required|uuid', 'representation_type' => 'required|in:individual,company,public_entity,association,partner',
|
|
'organization_id' => 'nullable|uuid|exists:organizations,id', 'opportunity_types' => 'required|array|min:1', 'opportunity_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)],
|
|
'document_ids' => 'required|array|min:1', 'document_ids.*' => 'required|uuid|exists:documents,id', 'reason' => 'required|string|min:20|max:1000'])->validate();
|
|
$organization = $data['organization_id'] ?? null;
|
|
abort_unless(($data['representation_type'] === 'individual') === ($organization === null), 422, 'Pessoa singular usa âmbito próprio; as entidades exigem organização.');
|
|
abort_unless($this->access->represents($actor, $organization), 403, 'Representação ativa de escrita necessária.');
|
|
foreach ($data['document_ids'] as $id) {
|
|
abort_unless(DB::table('documents')->where('id', $id)->where('uploaded_by', $actor->id)->whereNull('deleted_at')->where('status', 'active')
|
|
->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists(), 422, 'Comprovativo próprio, íntegro e analisado necessário.');
|
|
}
|
|
|
|
return DB::transaction(function () use ($actor, $data, $organization) {
|
|
DB::select('SELECT pg_advisory_xact_lock(101026, 40000)');
|
|
$existing = DB::table('portal_profile_requests')->where('creation_key', $data['creation_key'])->first();
|
|
if ($existing) {
|
|
abort_unless((int) $existing->user_id === $actor->id, 403);
|
|
abort_unless($existing->organization_id === $organization && $existing->representation_type === $data['representation_type']
|
|
&& json_decode($existing->opportunity_types, true) === $data['opportunity_types'] && json_decode($existing->document_ids, true) === $data['document_ids'] && $existing->reason === $data['reason'], 409, 'Chave já usada com outro pedido.');
|
|
|
|
return $existing->id;
|
|
}
|
|
$id = (string) Str::uuid();
|
|
DB::table('portal_profile_requests')->insert(['id' => $id, 'user_id' => $actor->id, 'organization_id' => $organization,
|
|
'representation_type' => $data['representation_type'], 'opportunity_types' => json_encode($data['opportunity_types'], JSON_THROW_ON_ERROR),
|
|
'document_ids' => json_encode($data['document_ids'], JSON_THROW_ON_ERROR), 'reason' => $data['reason'], 'creation_key' => $data['creation_key'], 'created_at' => now(), 'updated_at' => now()]);
|
|
$this->access->audit($actor, 'promoter_requested', ['request_id' => $id, 'types' => $data['opportunity_types']]);
|
|
|
|
return $id;
|
|
});
|
|
}
|
|
|
|
/** @param array<string,mixed> $input */
|
|
public function decide(User $actor, string $id, array $input): void
|
|
{
|
|
$this->administrative($actor);
|
|
$data = Validator::make($input, ['version' => 'required|integer|min:1', 'decision' => 'required|in:approved,rejected', 'reason' => 'required|string|min:20|max:1000',
|
|
'verified_identity' => 'required|boolean|accepted_if:decision,approved', 'verified_representation' => 'required|boolean|accepted_if:decision,approved', 'verified_documents' => 'required|boolean|accepted_if:decision,approved',
|
|
'approved_types' => 'present|array', 'approved_types.*' => ['required', Rule::in(OpportunityManagement::CATEGORIES)], 'expires_at' => 'nullable|date|after:now'])->validate();
|
|
DB::transaction(function () use ($actor, $id, $data) {
|
|
$row = DB::table('portal_profile_requests')->where('id', $id)->lockForUpdate()->first();
|
|
abort_unless($row !== null, 404);
|
|
abort_if((int) $row->user_id === $actor->id, 403, 'Não pode aprovar o seu pedido.');
|
|
abort_unless($row->status === 'pending' && (int) $row->version === (int) $data['version'], 409);
|
|
$requested = json_decode($row->opportunity_types, true, 512, JSON_THROW_ON_ERROR);
|
|
abort_unless(array_diff($data['approved_types'], $requested) === [], 422, 'Só podem ser aprovados tipos pedidos.');
|
|
$subject = User::whereKey($row->user_id)->firstOrFail();
|
|
if ($data['decision'] === 'approved') {
|
|
abort_unless($this->access->represents($subject, $row->organization_id), 403, 'Representação entretanto revogada.');
|
|
foreach (json_decode($row->document_ids, true) as $document) {
|
|
abort_unless(DB::table('documents')->where('id', $document)->where('uploaded_by', $subject->id)->whereNull('deleted_at')->where('status', 'active')
|
|
->where('malware_scan_status', 'clean')->whereColumn('sha256', 'malware_scanned_sha256')->exists(), 422, 'Comprovativo entretanto indisponível.');
|
|
}
|
|
}
|
|
if ($data['decision'] === 'approved') {
|
|
abort_unless($data['approved_types'] !== [], 422, 'Escolha ao menos um tipo.');
|
|
$this->insertGrant((string) Str::uuid(), $actor, ['user_id' => $subject->id, 'role' => 'promoter', 'organization_id' => $row->organization_id,
|
|
'opportunity_types' => $data['approved_types'], 'request_id' => $id, 'source' => 'documented-promoter-request', 'expires_at' => $data['expires_at'] ?? null]);
|
|
}
|
|
DB::table('portal_profile_requests')->where('id', $id)->update(['status' => $data['decision'], 'version' => $row->version + 1, 'decided_by' => $actor->id,
|
|
'decision_reason' => $data['reason'], 'decided_at' => now(), 'updated_at' => now()]);
|
|
$this->access->audit($actor, 'promoter_request_decided', ['request_id' => $id, 'decision' => $data['decision'], 'approved_types' => $data['approved_types'], 'reason' => $data['reason'], 'checks' => array_intersect_key($data, array_flip(['verified_identity', 'verified_representation', 'verified_documents']))]);
|
|
});
|
|
}
|
|
}
|