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 $input */ public function grant(User $actor, array $input): string { $this->administrative($actor); if (($input['internal_designation'] ?? false) === true) { return app(PortalInternalDesignation::class)->grant($actor, $input); } if (($input['role'] ?? null) === 'technical_support') { abort_unless(app(OpportunityTechnicalSupport::class)->installed(), 503, 'Instale a actualização do perfil de Suporte técnico antes de o atribuir.'); } $data = Validator::make($input, ['user_id' => 'required|integer|exists:users,id', 'role' => ['required', Rule::in(['promoter', 'validator', 'publisher', 'auditor', 'technical_support'])], '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 $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, 'policy_version' => $data['role'] === 'technical_support' ? 'profiles-technical-support-20261103' : PortalProfileAccess::VERSION, '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 $input */ public function request(User $actor, array $input): string { abort_unless($this->access->governanceAvailable() && $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 $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']))]); }); } }