Files
Investidor-V2/app/Services/PrivateOfferConfidentiality.php
T

218 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** The same opportunity owns consent, grants and history. No automatic publication. */
final class PrivateOfferConfidentiality
{
public function internal(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return $access->canView($actor, $record) && $access->mfa($actor) && ! $access->has($actor, 'auditor') && ! $access->conflict($actor, $record)
&& $access->scoped($actor, 'validator', $record->organization_id, $record->opportunity_category);
}
public function seller(User $actor, Opportunity $record): bool
{
$access = app(PortalProfileAccess::class);
return (int) $record->owner_user_id === $actor->id && $access->promoterRecordScope($actor, $record)
&& $access->canCreate($actor, $record->organization_id, $record->opportunity_category) && $access->operational($actor, 'edit', $record->id);
}
/** @return array<string,mixed>|null */
public function nda(): ?array
{
$definition = config('private_offers.nda');
if (! is_array($definition) || empty($definition['approval_reference']) || empty($definition['document_id']) || empty($definition['version']) || empty($definition['sha256'])) {
return null;
}
$doc = DB::table('documents')->where('id', $definition['document_id'])->whereNull('deleted_at')->first();
if (! $doc || $doc->status !== 'active' || $doc->malware_scan_status !== 'clean' || (int) $doc->version_no !== (int) $definition['version']
|| ! hash_equals(trim($doc->sha256), $definition['sha256']) || ! hash_equals(trim($doc->sha256), trim((string) $doc->malware_scanned_sha256))) {
return null;
}
try {
app(VerifiedDocumentBinary::class)->respond($doc);
} catch (HttpExceptionInterface $error) {
return null;
}
$result = ['document_id' => $doc->id, 'version' => (int) $doc->version_no, 'sha256' => trim($doc->sha256), 'approval_reference' => $definition['approval_reference']];
return [...$result, 'fingerprint' => hash('sha256', json_encode($result, JSON_THROW_ON_ERROR))];
}
private function company(int $id, bool $lock = false): Opportunity
{
$record = Opportunity::query()->when($lock, fn ($q) => $q->lockForUpdate())->findOrFail($id);
abort_unless($record->isPrivateOfferPreparation() && $record->opportunity_category === 'business_sale', 404);
return $record;
}
private function approvedHash(Opportunity $record): string
{
abort_unless(app(OfferAvailability::class)->record($record)['state'] === 'available', 409, 'A oferta não está disponível.');
$hash = $record->workflow_context['approval']['hash'] ?? null;
abort_unless(is_string($hash) && app(PortalProfileAccess::class)->state($record) === 'approved', 409, 'Esta versão não tem aprovação editorial actual.');
try {
$snapshot = app(PrivateOfferPreparation::class)->snapshot($record);
} catch (ValidationException $error) {
abort(409, 'A versão ou os suportes exigem reconferência editorial.');
}
abort_unless(hash_equals($hash, $snapshot['hash']), 409, 'A versão mudou; é necessária reconferência editorial.');
return $hash;
}
/** PostgreSQL jsonb reorders object keys; identity is the version and checksum, not array order.
* @param array<string,mixed> $nda */
private function sameNda(mixed $accepted, array $nda): bool
{
if (! is_array($accepted)) {
return false;
}
foreach (['document_id', 'version', 'sha256', 'approval_reference', 'fingerprint'] as $key) {
if (($accepted[$key] ?? null) !== $nda[$key]) {
return false;
}
}
return true;
}
/** @return array<string,mixed> */
public function overview(User $actor, int $id): array
{
$record = $this->company($id);
// An interested party learns no private title, contacts, documents or financial values.
$seller = $this->seller($actor, $record);
$nda = $this->nda();
$context = $record->workflow_context['offer_confidentiality'] ?? [];
$currentHash = null;
try {
$currentHash = $this->approvedHash($record);
} catch (HttpExceptionInterface $error) {
// A previous acceptance remains withdrawable even if the offer was revised.
abort_unless($seller || isset($context['acceptances'][(string) $actor->id]), 404);
}
$history = array_values(array_filter($context['events'] ?? [], fn ($event) => $seller || $event['interested_user_id'] === $actor->id));
return ['offer_id' => $id, 'nda' => $nda, 'nda_pending' => $nda === null, 'version_hash' => $record->workflow_context['approval']['hash'] ?? null,
'etag' => $seller ? app(OpportunityManagement::class)->etag($record) : null,
'can_accept' => ! $seller && $nda !== null && $currentHash !== null && ! app(PortalProfileAccess::class)->conflict($actor, $record),
'can_authorize' => $seller && $nda !== null && $currentHash !== null,
'seller' => $seller, 'requests' => $seller ? array_values($context['acceptances'] ?? []) : [],
'grants' => $seller ? array_values($context['grants'] ?? []) : [],
'history' => array_map(fn ($event) => array_intersect_key($event, array_flip(['id', 'action', 'actor_id', 'at', 'version_hash', 'nda'])), $history),
'acceptance' => $context['acceptances'][(string) $actor->id] ?? null];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function act(User $actor, int $id, array $input): array
{
$data = validator($input, ['action' => 'required|in:accept,authorize,revoke,withdraw', 'version_hash' => 'required|string|size:64',
'nda_sha256' => 'required_unless:action,revoke,withdraw|string|size:64', 'acceptance_id' => 'required_if:action,authorize|uuid',
'nda_fingerprint' => 'required_if:action,accept,authorize|string|size:64',
'interested_user_id' => 'required_if:action,authorize,revoke|integer|min:1', 'confirm' => 'required|accepted',
'reason' => ['nullable', 'required_if:action,authorize,revoke', 'string', 'max:1000', ...(in_array($input['action'] ?? null, ['authorize', 'revoke'], true) ? ['min:20'] : [])]])->validate();
return DB::transaction(function () use ($actor, $id, $data) {
$record = $this->company($id, true);
$context = $record->workflow_context;
$privacy = $context['offer_confidentiality'] ?? ['acceptances' => [], 'grants' => [], 'events' => []];
$action = $data['action'];
$target = in_array($action, ['authorize', 'revoke'], true) ? (string) $data['interested_user_id'] : (string) $actor->id;
$before = ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null];
if (in_array($action, ['authorize', 'revoke'], true)) {
abort_unless($this->seller($actor, $record), 403);
} elseif ($action === 'accept') {
abort_if($this->seller($actor, $record) || app(PortalProfileAccess::class)->conflict($actor, $record), 403);
}
if (in_array($action, ['accept', 'authorize'], true)) {
$nda = $this->nda();
abort_unless($nda !== null, 409, 'O exemplar aprovado do NDA ainda não foi disponibilizado.');
abort_unless(hash_equals($this->approvedHash($record), $data['version_hash']) && hash_equals($nda['sha256'], $data['nda_sha256'])
&& hash_equals($nda['fingerprint'], $data['nda_fingerprint']), 409);
$subject = User::findOrFail((int) $target);
abort_unless($subject->hasVerifiedEmail() && ! app(PortalProfileAccess::class)->conflict($subject, $record), 403);
if ($action === 'accept') {
$privacy['acceptances'][$target] = ['id' => (string) Str::uuid(), 'actor_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'nda' => $nda, 'access_revision' => (int) $subject->portal_access_revision];
unset($privacy['grants'][$target]); // A fresh acceptance never reuses a previous seller decision.
} else {
$acceptance = $privacy['acceptances'][$target] ?? null;
abort_unless($acceptance && $acceptance['id'] === $data['acceptance_id'] && $acceptance['version_hash'] === $data['version_hash']
&& $this->sameNda($acceptance['nda'], $nda) && $acceptance['access_revision'] === (int) $subject->portal_access_revision, 409);
$privacy['grants'][$target] = ['interested_user_id' => (int) $target, 'seller_id' => $actor->id, 'at' => now()->toIso8601String(),
'version_hash' => $data['version_hash'], 'acceptance_id' => $acceptance['id'], 'nda' => $nda];
}
} else {
$previous = $privacy[$action === 'withdraw' ? 'acceptances' : 'grants'][$target] ?? null;
abort_unless($previous && $previous['version_hash'] === $data['version_hash'], 409);
unset($privacy['grants'][$target]);
if ($action === 'withdraw') {
unset($privacy['acceptances'][$target]);
}
}
$event = ['id' => (string) Str::uuid(), 'action' => $action, 'actor_id' => $actor->id, 'interested_user_id' => (int) $target,
'version_hash' => $data['version_hash'], 'at' => now()->toIso8601String(), 'nda_sha256' => $data['nda_sha256'] ?? null,
'nda' => $nda ?? ($before['acceptance']['nda'] ?? $before['grant']['nda'] ?? null)];
$privacy['events'][] = [...$event, 'reason' => Crypt::encryptString($data['reason'] ?? ''), 'before' => $before,
'after' => ['acceptance' => $privacy['acceptances'][$target] ?? null, 'grant' => $privacy['grants'][$target] ?? null]];
$context['offer_confidentiality'] = $privacy;
$record->workflow_context = $context;
$record->management_version++;
$record->save();
app(PortalProfileAccess::class)->audit($actor, 'offer_confidentiality_'.$action, ['record_id' => $id, ...$event]);
return ['recorded' => true, 'event_id' => $event['id']];
});
}
/** @return array<string,mixed> */
public function read(User $actor, int $id): array
{
return DB::transaction(function () use ($actor, $id) {
$record = $this->company($id, true);
$nda = $this->nda();
$privacy = $record->workflow_context['offer_confidentiality'] ?? [];
$accept = $privacy['acceptances'][(string) $actor->id] ?? null;
$grant = $privacy['grants'][(string) $actor->id] ?? null;
$hash = $this->approvedHash($record);
$seller = User::find($record->owner_user_id);
$subject = User::find($actor->id);
$allowed = $nda && $accept && $grant && $seller && $subject && $this->seller($seller, $record) && $subject->hasVerifiedEmail()
&& $accept['access_revision'] === (int) $subject->portal_access_revision && $grant['seller_id'] === $seller->id
&& $grant['acceptance_id'] === $accept['id'] && $grant['version_hash'] === $hash && $accept['version_hash'] === $hash
&& $this->sameNda($grant['nda'], $nda) && $this->sameNda($accept['nda'], $nda) && ! app(PortalProfileAccess::class)->conflict($actor, $record);
app(PortalProfileAccess::class)->audit($actor, $allowed ? 'offer_confidential_access' : 'offer_confidential_denied', ['record_id' => $id, 'version_hash' => $hash,
'nda' => $nda, 'acceptance_id' => $accept['id'] ?? null]);
// Return the refusal after transaction commit so the denied-access audit survives.
if (! $allowed) {
return ['denied' => true];
}
$business = $record->workflow_context['submission']['payload']['private_offer']['business'] ?? [];
foreach (['sector_id' => 'sector', 'secondary_sector_id' => 'secondary_sector'] as $field => $label) {
if (isset($business[$field])) {
$business[$label] = DB::table('sectors')->where('id', $business[$field])->value('name');
}
unset($business[$field]);
}
return ['offer_id' => $id, 'version_hash' => $hash, 'business' => $business];
});
}
}