468 lines
16 KiB
PHP
468 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\GovernanceDecisions;
|
|
|
|
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
use App\Filament\Resources\FindingExceptionResource;
|
|
use App\Filament\Resources\StoredReportResource;
|
|
use App\Models\EvidenceSnapshot;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\FindingExceptionDecision;
|
|
use App\Models\FindingExceptionEvidenceReference;
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\OperationRun;
|
|
use App\Models\StoredReport;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunLinks;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
final readonly class GovernanceDecisionRegisterBuilder
|
|
{
|
|
private const int RECENTLY_CLOSED_DAYS = 30;
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
private const array TERMINAL_STATUSES = [
|
|
FindingException::STATUS_REJECTED,
|
|
FindingException::STATUS_REVOKED,
|
|
FindingException::STATUS_SUPERSEDED,
|
|
];
|
|
|
|
/**
|
|
* @param array<int, ManagedEnvironment> $visibleTenants
|
|
* @return array{
|
|
* rows: list<array<string, mixed>>,
|
|
* counts: array{open: int, recently_closed: int},
|
|
* }
|
|
*/
|
|
public function build(Workspace $workspace, array $visibleTenants, string $registerState = 'open'): array
|
|
{
|
|
$visibleTenantIds = array_values(array_map(
|
|
static fn (ManagedEnvironment $tenant): int => (int) $tenant->getKey(),
|
|
$visibleTenants,
|
|
));
|
|
|
|
if ($visibleTenantIds === []) {
|
|
return [
|
|
'rows' => [],
|
|
'counts' => [
|
|
'open' => 0,
|
|
'recently_closed' => 0,
|
|
],
|
|
];
|
|
}
|
|
|
|
$rows = FindingException::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->whereIn('managed_environment_id', $visibleTenantIds)
|
|
->with(['tenant', 'owner:id,name', 'currentDecision', 'evidenceReferences', 'finding.currentRun', 'finding.baselineRun'])
|
|
->get()
|
|
->map(fn (FindingException $exception): ?array => $this->buildRow($exception))
|
|
->filter()
|
|
->values();
|
|
|
|
/** @var Collection<int, array<string, mixed>> $openRows */
|
|
$openRows = $rows
|
|
->where('register_state', 'open')
|
|
->sortBy([
|
|
['due_at', 'asc'],
|
|
['exception_id', 'asc'],
|
|
])
|
|
->values();
|
|
|
|
/** @var Collection<int, array<string, mixed>> $recentlyClosedRows */
|
|
$recentlyClosedRows = $rows
|
|
->where('register_state', 'recently_closed')
|
|
->sortByDesc('decision_at')
|
|
->values();
|
|
|
|
return [
|
|
'rows' => match ($registerState) {
|
|
'recently_closed' => $recentlyClosedRows->all(),
|
|
default => $openRows->all(),
|
|
},
|
|
'counts' => [
|
|
'open' => $openRows->count(),
|
|
'recently_closed' => $recentlyClosedRows->count(),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function buildRow(FindingException $exception): ?array
|
|
{
|
|
$currentDecision = $exception->currentDecision;
|
|
|
|
if (! $currentDecision instanceof FindingExceptionDecision) {
|
|
return null;
|
|
}
|
|
|
|
$registerState = $this->resolveRegisterState($exception, $currentDecision);
|
|
|
|
if ($registerState === null) {
|
|
return null;
|
|
}
|
|
|
|
$proofMetadata = $this->buildProofMetadata($exception);
|
|
$operationRunMetadata = $this->buildOperationRunMetadata($exception);
|
|
|
|
return [
|
|
'exception_id' => (int) $exception->getKey(),
|
|
'register_state' => $registerState,
|
|
'tenant_name' => $exception->tenant?->name,
|
|
'owner_name' => $exception->owner?->name,
|
|
'status' => (string) $exception->status,
|
|
'current_validity_state' => (string) $exception->current_validity_state,
|
|
'next_action_label' => $registerState === 'open'
|
|
? $this->resolveNextActionLabel($exception, $currentDecision)
|
|
: 'Decision closed',
|
|
'closure_reason' => $registerState === 'recently_closed'
|
|
? (string) $currentDecision->reason
|
|
: null,
|
|
'due_at' => $exception->review_due_at ?? $exception->expires_at,
|
|
'decision_at' => $currentDecision->decided_at,
|
|
...$proofMetadata,
|
|
...$operationRunMetadata,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* proof_count: int,
|
|
* proof_state: string,
|
|
* proof_label: string,
|
|
* proof_url: string|null,
|
|
* proof_url_label: string|null,
|
|
* proof_unavailable_reason: string|null,
|
|
* }
|
|
*/
|
|
private function buildProofMetadata(FindingException $exception): array
|
|
{
|
|
$references = $this->evidenceReferences($exception);
|
|
$proofCount = $references->count();
|
|
|
|
if ($proofCount === 0) {
|
|
return [
|
|
'proof_count' => 0,
|
|
'proof_state' => 'not_linked',
|
|
'proof_label' => 'No linked proof',
|
|
'proof_url' => null,
|
|
'proof_url_label' => null,
|
|
'proof_unavailable_reason' => null,
|
|
];
|
|
}
|
|
|
|
if ($proofCount === 1) {
|
|
$directLink = $this->resolveDirectProofLink($exception, $references->first());
|
|
|
|
if (is_array($directLink)) {
|
|
return [
|
|
'proof_count' => $proofCount,
|
|
'proof_state' => $directLink['state'],
|
|
'proof_label' => $this->proofLabel($proofCount),
|
|
'proof_url' => $directLink['url'],
|
|
'proof_url_label' => $directLink['label'],
|
|
'proof_unavailable_reason' => null,
|
|
];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'proof_count' => $proofCount,
|
|
'proof_state' => 'linked_detail_section',
|
|
'proof_label' => $this->proofLabel($proofCount),
|
|
'proof_url' => $this->findingExceptionDetailUrl($exception),
|
|
'proof_url_label' => 'View proof',
|
|
'proof_unavailable_reason' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* operation_run_state: string,
|
|
* operation_run_url: string|null,
|
|
* operation_run_label: string,
|
|
* }
|
|
*/
|
|
private function buildOperationRunMetadata(FindingException $exception): array
|
|
{
|
|
$run = $this->sourceFindingOperationRun($exception);
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
$runs = $this->evidenceOperationRunsFor($exception)
|
|
->unique(fn (OperationRun $run): int => (int) $run->getKey())
|
|
->values();
|
|
|
|
if ($runs->count() !== 1) {
|
|
return [
|
|
'operation_run_state' => $runs->isEmpty() ? 'not_linked' : 'run_not_available',
|
|
'operation_run_url' => null,
|
|
'operation_run_label' => 'No operation linked',
|
|
];
|
|
}
|
|
|
|
/** @var OperationRun $run */
|
|
$run = $runs->first();
|
|
}
|
|
|
|
if (! $this->canViewOperationRun($run, $exception)) {
|
|
return [
|
|
'operation_run_state' => 'run_not_available',
|
|
'operation_run_url' => null,
|
|
'operation_run_label' => 'No operation linked',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'operation_run_state' => 'linked_run',
|
|
'operation_run_url' => OperationRunLinks::tenantlessView($run),
|
|
'operation_run_label' => 'View operation',
|
|
];
|
|
}
|
|
|
|
private function sourceFindingOperationRun(FindingException $exception): ?OperationRun
|
|
{
|
|
$finding = $exception->relationLoaded('finding')
|
|
? $exception->finding
|
|
: $exception->finding()->with(['currentRun', 'baselineRun'])->first();
|
|
|
|
if (! $finding instanceof Finding) {
|
|
return null;
|
|
}
|
|
|
|
if ($finding->currentRun instanceof OperationRun) {
|
|
return $finding->currentRun;
|
|
}
|
|
|
|
return $finding->baselineRun instanceof OperationRun
|
|
? $finding->baselineRun
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, FindingExceptionEvidenceReference>
|
|
*/
|
|
private function evidenceReferences(FindingException $exception): Collection
|
|
{
|
|
if ($exception->relationLoaded('evidenceReferences')) {
|
|
return $exception->evidenceReferences
|
|
->filter(fn (mixed $reference): bool => $reference instanceof FindingExceptionEvidenceReference)
|
|
->values();
|
|
}
|
|
|
|
return $exception->evidenceReferences()->get();
|
|
}
|
|
|
|
/**
|
|
* @return array{state: string, url: string, label: string}|null
|
|
*/
|
|
private function resolveDirectProofLink(FindingException $exception, ?FindingExceptionEvidenceReference $reference): ?array
|
|
{
|
|
if (! $reference instanceof FindingExceptionEvidenceReference) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->isEvidenceSnapshotReference($reference)) {
|
|
$snapshot = $this->resolveEvidenceSnapshot($exception, $reference);
|
|
|
|
if ($snapshot instanceof EvidenceSnapshot && $this->canViewEvidenceSnapshot($snapshot)) {
|
|
return [
|
|
'state' => 'linked_evidence',
|
|
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin', tenant: $snapshot->tenant),
|
|
'label' => 'View evidence',
|
|
];
|
|
}
|
|
}
|
|
|
|
if ($this->isStoredReportReference($reference)) {
|
|
$report = $this->resolveStoredReport($exception, $reference);
|
|
|
|
if ($report instanceof StoredReport && $this->canViewStoredReport($report)) {
|
|
return [
|
|
'state' => 'linked_report',
|
|
'url' => StoredReportResource::getUrl('view', ['record' => $report], panel: 'admin', tenant: $report->tenant),
|
|
'label' => 'View report',
|
|
];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, OperationRun>
|
|
*/
|
|
private function evidenceOperationRunsFor(FindingException $exception): Collection
|
|
{
|
|
return $this->evidenceReferences($exception)
|
|
->filter(fn (FindingExceptionEvidenceReference $reference): bool => $this->isEvidenceSnapshotReference($reference))
|
|
->map(fn (FindingExceptionEvidenceReference $reference): ?EvidenceSnapshot => $this->resolveEvidenceSnapshot($exception, $reference))
|
|
->filter(fn (?EvidenceSnapshot $snapshot): bool => $snapshot instanceof EvidenceSnapshot)
|
|
->map(fn (EvidenceSnapshot $snapshot): ?OperationRun => $snapshot->operationRun)
|
|
->filter(fn (?OperationRun $run): bool => $run instanceof OperationRun)
|
|
->values();
|
|
}
|
|
|
|
private function resolveEvidenceSnapshot(FindingException $exception, FindingExceptionEvidenceReference $reference): ?EvidenceSnapshot
|
|
{
|
|
$sourceId = $this->numericSourceId($reference);
|
|
|
|
if ($sourceId === null) {
|
|
return null;
|
|
}
|
|
|
|
return EvidenceSnapshot::query()
|
|
->with(['tenant', 'operationRun'])
|
|
->whereKey($sourceId)
|
|
->where('workspace_id', (int) $exception->workspace_id)
|
|
->where('managed_environment_id', (int) $exception->managed_environment_id)
|
|
->first();
|
|
}
|
|
|
|
private function resolveStoredReport(FindingException $exception, FindingExceptionEvidenceReference $reference): ?StoredReport
|
|
{
|
|
$sourceId = $this->numericSourceId($reference);
|
|
|
|
if ($sourceId === null) {
|
|
return null;
|
|
}
|
|
|
|
return StoredReport::query()
|
|
->with('tenant')
|
|
->whereKey($sourceId)
|
|
->where('workspace_id', (int) $exception->workspace_id)
|
|
->where('managed_environment_id', (int) $exception->managed_environment_id)
|
|
->whereIn('report_type', StoredReportResource::supportedReportTypes())
|
|
->first();
|
|
}
|
|
|
|
private function canViewEvidenceSnapshot(EvidenceSnapshot $snapshot): bool
|
|
{
|
|
$user = auth()->user();
|
|
$tenant = $snapshot->tenant;
|
|
|
|
return $user instanceof User
|
|
&& $tenant instanceof ManagedEnvironment
|
|
&& $user->canAccessTenant($tenant)
|
|
&& $user->can(Capabilities::EVIDENCE_VIEW, $tenant);
|
|
}
|
|
|
|
private function canViewStoredReport(StoredReport $report): bool
|
|
{
|
|
$user = auth()->user();
|
|
$tenant = $report->tenant;
|
|
$capability = StoredReportResource::capabilityForReportType((string) $report->report_type);
|
|
|
|
return $user instanceof User
|
|
&& $tenant instanceof ManagedEnvironment
|
|
&& is_string($capability)
|
|
&& $user->canAccessTenant($tenant)
|
|
&& $user->can($capability, $tenant);
|
|
}
|
|
|
|
private function canViewOperationRun(OperationRun $run, FindingException $exception): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
return $user instanceof User
|
|
&& $this->runMatchesExceptionScope($run, $exception)
|
|
&& Gate::forUser($user)->allows('view', $run);
|
|
}
|
|
|
|
private function runMatchesExceptionScope(OperationRun $run, FindingException $exception): bool
|
|
{
|
|
return (int) $run->workspace_id === (int) $exception->workspace_id
|
|
&& (int) $run->managed_environment_id === (int) $exception->managed_environment_id;
|
|
}
|
|
|
|
private function findingExceptionDetailUrl(FindingException $exception): ?string
|
|
{
|
|
$tenant = $exception->tenant;
|
|
|
|
if (! $tenant instanceof ManagedEnvironment) {
|
|
return null;
|
|
}
|
|
|
|
return FindingExceptionResource::getUrl('view', ['record' => $exception], panel: 'admin', tenant: $tenant);
|
|
}
|
|
|
|
private function proofLabel(int $proofCount): string
|
|
{
|
|
return $proofCount === 1
|
|
? '1 proof item'
|
|
: $proofCount.' proof items';
|
|
}
|
|
|
|
private function numericSourceId(FindingExceptionEvidenceReference $reference): ?int
|
|
{
|
|
if (! is_string($reference->source_id) && ! is_numeric($reference->source_id)) {
|
|
return null;
|
|
}
|
|
|
|
$sourceId = trim((string) $reference->source_id);
|
|
|
|
if ($sourceId === '' || ! ctype_digit($sourceId)) {
|
|
return null;
|
|
}
|
|
|
|
$sourceId = (int) $sourceId;
|
|
|
|
return $sourceId > 0 ? $sourceId : null;
|
|
}
|
|
|
|
private function isEvidenceSnapshotReference(FindingExceptionEvidenceReference $reference): bool
|
|
{
|
|
return in_array((string) $reference->source_type, ['evidence_snapshot', EvidenceSnapshot::class], true);
|
|
}
|
|
|
|
private function isStoredReportReference(FindingExceptionEvidenceReference $reference): bool
|
|
{
|
|
return in_array((string) $reference->source_type, ['stored_report', StoredReport::class], true);
|
|
}
|
|
|
|
private function resolveRegisterState(FindingException $exception, FindingExceptionDecision $currentDecision): ?string
|
|
{
|
|
$status = (string) $exception->status;
|
|
|
|
if (in_array($status, self::TERMINAL_STATUSES, true)) {
|
|
return $this->isRecentlyClosed($currentDecision->decided_at)
|
|
? 'recently_closed'
|
|
: null;
|
|
}
|
|
|
|
return 'open';
|
|
}
|
|
|
|
private function resolveNextActionLabel(FindingException $exception, FindingExceptionDecision $currentDecision): string
|
|
{
|
|
if ($exception->isPendingRenewal() || $currentDecision->decision_type === FindingExceptionDecision::TYPE_RENEWAL_REQUESTED) {
|
|
return 'Review renewal';
|
|
}
|
|
|
|
if ($exception->isPending()) {
|
|
return 'Review approval';
|
|
}
|
|
|
|
return 'Review follow-up';
|
|
}
|
|
|
|
private function isRecentlyClosed(?CarbonInterface $decidedAt): bool
|
|
{
|
|
if (! $decidedAt instanceof CarbonInterface) {
|
|
return false;
|
|
}
|
|
|
|
return $decidedAt->greaterThanOrEqualTo(now()->startOfDay()->subDays(self::RECENTLY_CLOSED_DAYS));
|
|
}
|
|
}
|