Implemented the accepted risk resolution guidance, including the AcceptedRiskResolutionAdapter, guidance cards, and updated related Filament views. Added unit, feature, and browser tests. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #425
574 lines
22 KiB
PHP
574 lines
22 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\ResolutionGuidance\Adapters;
|
|
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Models\FindingExceptionDecision;
|
|
use App\Services\Findings\FindingRiskGovernanceResolver;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Badges\BadgeRenderer;
|
|
use App\Support\ResolutionGuidance\ResolutionAction;
|
|
use App\Support\ResolutionGuidance\ResolutionCase;
|
|
use Illuminate\Support\Str;
|
|
|
|
final readonly class AcceptedRiskResolutionAdapter
|
|
{
|
|
public function __construct(
|
|
private FindingRiskGovernanceResolver $resolver,
|
|
) {}
|
|
|
|
/**
|
|
* @return array{
|
|
* key:string,
|
|
* scope:array<string, int|string>,
|
|
* severity:string,
|
|
* status:string,
|
|
* title:string,
|
|
* reason:string,
|
|
* impact:string,
|
|
* primary_action:array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* },
|
|
* secondary_actions:list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>,
|
|
* source_refs:list<array{type:string,id:int|string}>,
|
|
* evidence_refs:list<array{type:string,id:int|string}>,
|
|
* technical_details:array<string, string>
|
|
* }
|
|
*/
|
|
public function forQueue(
|
|
FindingException $exception,
|
|
?string $detailUrl = null,
|
|
?string $findingUrl = null,
|
|
): array {
|
|
return $this->buildCase($exception, 'queue', [
|
|
'detail_url' => $detailUrl,
|
|
'finding_url' => $findingUrl,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* key:string,
|
|
* scope:array<string, int|string>,
|
|
* severity:string,
|
|
* status:string,
|
|
* title:string,
|
|
* reason:string,
|
|
* impact:string,
|
|
* primary_action:array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* },
|
|
* secondary_actions:list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>,
|
|
* source_refs:list<array{type:string,id:int|string}>,
|
|
* evidence_refs:list<array{type:string,id:int|string}>,
|
|
* technical_details:array<string, string>
|
|
* }
|
|
*/
|
|
public function forDetail(
|
|
FindingException $exception,
|
|
?string $queueUrl = null,
|
|
?string $findingUrl = null,
|
|
): array {
|
|
return $this->buildCase($exception, 'detail', [
|
|
'queue_url' => $queueUrl,
|
|
'finding_url' => $findingUrl,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions
|
|
* @return array{
|
|
* key:string,
|
|
* scope:array<string, int|string>,
|
|
* severity:string,
|
|
* status:string,
|
|
* title:string,
|
|
* reason:string,
|
|
* impact:string,
|
|
* primary_action:array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* },
|
|
* secondary_actions:list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>,
|
|
* source_refs:list<array{type:string,id:int|string}>,
|
|
* evidence_refs:list<array{type:string,id:int|string}>,
|
|
* technical_details:array<string, string>
|
|
* }
|
|
*/
|
|
private function buildCase(FindingException $exception, string $surface, array $actions): array
|
|
{
|
|
$finding = $exception->relationLoaded('finding')
|
|
? $exception->finding
|
|
: $exception->finding()->withSubjectDisplayName()->first();
|
|
|
|
$case = $this->classifyCase($exception, $finding);
|
|
$title = $this->title($case, $exception);
|
|
$reason = $this->reason($case, $exception, $finding);
|
|
$impact = $this->impact($case);
|
|
$caseKey = 'accepted_risk.'.$case;
|
|
$primaryAction = $this->primaryAction(
|
|
$caseKey,
|
|
$case,
|
|
$surface,
|
|
$actions,
|
|
$this->dominantNextStep($case, $exception, $finding),
|
|
);
|
|
|
|
return ResolutionCase::make(
|
|
key: $caseKey,
|
|
scope: array_filter([
|
|
'type' => 'finding_exception',
|
|
'surface' => $surface,
|
|
'workspace_id' => (int) $exception->workspace_id,
|
|
'managed_environment_id' => (int) $exception->managed_environment_id,
|
|
'finding_exception_id' => (int) $exception->getKey(),
|
|
'finding_id' => (int) $exception->finding_id,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
severity: $this->severity($case),
|
|
status: $this->statusLabel($case),
|
|
title: $title,
|
|
reason: $reason,
|
|
impact: $impact,
|
|
primaryAction: $primaryAction,
|
|
secondaryActions: $this->secondaryActions(
|
|
$caseKey,
|
|
$surface,
|
|
$actions,
|
|
is_string($primaryAction['url'] ?? null) ? $primaryAction['url'] : null,
|
|
),
|
|
sourceRefs: $this->sourceRefs($exception),
|
|
evidenceRefs: $this->evidenceRefs($exception),
|
|
technicalDetails: $this->technicalDetails($exception, $finding),
|
|
);
|
|
}
|
|
|
|
private function classifyCase(FindingException $exception, ?Finding $finding): string
|
|
{
|
|
$status = $this->resolver->resolveExceptionStatus($exception);
|
|
$storedValidity = (string) $exception->current_validity_state;
|
|
$validity = $finding instanceof Finding
|
|
? $this->resolver->resolveGovernanceValidity($finding, $exception)
|
|
: $storedValidity;
|
|
$isPendingRenewal = $exception->isPendingRenewal();
|
|
|
|
if ($finding instanceof Finding && $exception->requiresFreshDecisionForFinding($finding)) {
|
|
return 'fresh_decision_required';
|
|
}
|
|
|
|
if (! $isPendingRenewal && $status === FindingException::STATUS_PENDING) {
|
|
return 'pending';
|
|
}
|
|
|
|
if ($storedValidity === FindingException::VALIDITY_MISSING_SUPPORT || $validity === FindingException::VALIDITY_MISSING_SUPPORT) {
|
|
return 'missing_support';
|
|
}
|
|
|
|
if ($status === FindingException::STATUS_EXPIRED || $validity === FindingException::VALIDITY_EXPIRED) {
|
|
return 'expired';
|
|
}
|
|
|
|
if ($status === FindingException::STATUS_REVOKED || $validity === FindingException::VALIDITY_REVOKED) {
|
|
return 'revoked_or_rejected';
|
|
}
|
|
|
|
if ($status === FindingException::STATUS_REJECTED || $validity === FindingException::VALIDITY_REJECTED) {
|
|
return 'revoked_or_rejected';
|
|
}
|
|
|
|
if ($status === FindingException::STATUS_EXPIRING || $validity === FindingException::VALIDITY_EXPIRING) {
|
|
return 'expiring';
|
|
}
|
|
|
|
if ($status === FindingException::STATUS_PENDING) {
|
|
return 'pending';
|
|
}
|
|
|
|
if ($this->missingGovernanceFields($exception) !== []) {
|
|
return 'incomplete_governance';
|
|
}
|
|
|
|
return 'ready';
|
|
}
|
|
|
|
private function severity(string $case): string
|
|
{
|
|
return match ($case) {
|
|
'ready' => 'success',
|
|
'expiring', 'pending', 'fresh_decision_required', 'incomplete_governance' => 'warning',
|
|
default => 'danger',
|
|
};
|
|
}
|
|
|
|
private function statusLabel(string $case): string
|
|
{
|
|
return match ($case) {
|
|
'ready' => __('localization.accepted_risk_guidance.status_ready'),
|
|
'expiring', 'pending', 'fresh_decision_required', 'incomplete_governance' => __('localization.accepted_risk_guidance.status_action_required'),
|
|
default => __('localization.accepted_risk_guidance.status_blocked'),
|
|
};
|
|
}
|
|
|
|
private function title(string $case, FindingException $exception): string
|
|
{
|
|
return match ($case) {
|
|
'ready' => __('localization.accepted_risk_guidance.title_ready'),
|
|
'expiring' => __('localization.accepted_risk_guidance.title_expiring'),
|
|
'expired' => __('localization.accepted_risk_guidance.title_expired'),
|
|
'pending' => $exception->isPendingRenewal()
|
|
? __('localization.accepted_risk_guidance.title_pending_renewal')
|
|
: __('localization.accepted_risk_guidance.title_pending'),
|
|
'missing_support' => __('localization.accepted_risk_guidance.title_missing_support'),
|
|
'fresh_decision_required' => __('localization.accepted_risk_guidance.title_fresh_decision_required'),
|
|
'incomplete_governance' => __('localization.accepted_risk_guidance.title_incomplete_governance'),
|
|
default => (string) (
|
|
$this->resolver->resolveExceptionStatus($exception) === FindingException::STATUS_REJECTED
|
|
? __('localization.accepted_risk_guidance.title_rejected')
|
|
: __('localization.accepted_risk_guidance.title_revoked')
|
|
),
|
|
};
|
|
}
|
|
|
|
private function reason(string $case, FindingException $exception, ?Finding $finding): string
|
|
{
|
|
return match ($case) {
|
|
'ready' => __('localization.accepted_risk_guidance.reason_ready'),
|
|
'expiring' => __('localization.accepted_risk_guidance.reason_expiring'),
|
|
'expired' => __('localization.accepted_risk_guidance.reason_expired'),
|
|
'pending' => $exception->isPendingRenewal()
|
|
? __('localization.accepted_risk_guidance.reason_pending_renewal')
|
|
: __('localization.accepted_risk_guidance.reason_pending'),
|
|
'missing_support' => __('localization.accepted_risk_guidance.reason_missing_support'),
|
|
'fresh_decision_required' => __('localization.accepted_risk_guidance.reason_fresh_decision_required'),
|
|
'incomplete_governance' => __('localization.accepted_risk_guidance.reason_incomplete_governance', [
|
|
'fields' => implode(', ', $this->missingGovernanceFields($exception)),
|
|
]),
|
|
default => __('localization.accepted_risk_guidance.reason_revoked_or_rejected'),
|
|
};
|
|
}
|
|
|
|
private function impact(string $case): string
|
|
{
|
|
return match ($case) {
|
|
'ready' => __('localization.accepted_risk_guidance.impact_ready'),
|
|
'expiring' => __('localization.accepted_risk_guidance.impact_expiring'),
|
|
'expired' => __('localization.accepted_risk_guidance.impact_expired'),
|
|
'pending' => __('localization.accepted_risk_guidance.impact_pending'),
|
|
'missing_support' => __('localization.accepted_risk_guidance.impact_missing_support'),
|
|
'fresh_decision_required' => __('localization.accepted_risk_guidance.impact_fresh_decision_required'),
|
|
'incomplete_governance' => __('localization.accepted_risk_guidance.impact_incomplete_governance'),
|
|
default => __('localization.accepted_risk_guidance.impact_revoked_or_rejected'),
|
|
};
|
|
}
|
|
|
|
private function dominantNextStep(string $case, FindingException $exception, ?Finding $finding): string
|
|
{
|
|
return match ($case) {
|
|
'ready' => __('localization.accepted_risk_guidance.next_step_ready'),
|
|
'expiring' => __('localization.accepted_risk_guidance.next_step_expiring'),
|
|
'expired' => __('localization.accepted_risk_guidance.next_step_expired'),
|
|
'pending' => $exception->isPendingRenewal()
|
|
? __('localization.accepted_risk_guidance.next_step_pending_renewal')
|
|
: __('localization.accepted_risk_guidance.next_step_pending'),
|
|
'revoked_or_rejected' => __('localization.accepted_risk_guidance.next_step_revoked_or_rejected'),
|
|
'missing_support' => __('localization.accepted_risk_guidance.next_step_missing_support'),
|
|
'fresh_decision_required' => __('localization.accepted_risk_guidance.next_step_fresh_decision_required'),
|
|
'incomplete_governance' => __('localization.accepted_risk_guidance.next_step_incomplete_governance'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions
|
|
* @return array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }
|
|
*/
|
|
private function primaryAction(string $caseKey, string $case, string $surface, array $actions, string $label): array
|
|
{
|
|
$url = $surface === 'detail' && $case === 'pending'
|
|
? ($actions['queue_url'] ?? null)
|
|
: null;
|
|
|
|
if (is_string($url) && trim($url) !== '') {
|
|
return ResolutionAction::fromArray([
|
|
'key' => $caseKey.'.primary_action',
|
|
'label' => $label,
|
|
'url' => trim($url),
|
|
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'kind' => 'environment_link',
|
|
], $caseKey.'.primary_action', $label);
|
|
}
|
|
|
|
return ResolutionAction::none(
|
|
key: $caseKey.'.primary_action',
|
|
label: $label,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array{detail_url?: ?string, finding_url?: ?string, queue_url?: ?string} $actions
|
|
* @return list<array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }>
|
|
*/
|
|
private function secondaryActions(string $caseKey, string $surface, array $actions, ?string $primaryUrl = null): array
|
|
{
|
|
$secondaryActions = $surface === 'queue'
|
|
? [
|
|
$this->navigationAction(
|
|
$caseKey.'.open_exception',
|
|
__('localization.accepted_risk_guidance.action_open_exception'),
|
|
$actions['detail_url'] ?? null,
|
|
),
|
|
$this->navigationAction(
|
|
$caseKey.'.open_finding',
|
|
__('localization.accepted_risk_guidance.action_open_finding'),
|
|
$actions['finding_url'] ?? null,
|
|
),
|
|
]
|
|
: [
|
|
$this->navigationAction(
|
|
$caseKey.'.open_finding',
|
|
__('localization.accepted_risk_guidance.action_open_finding'),
|
|
$actions['finding_url'] ?? null,
|
|
),
|
|
$this->navigationAction(
|
|
$caseKey.'.open_queue',
|
|
__('localization.accepted_risk_guidance.action_open_queue'),
|
|
$actions['queue_url'] ?? null,
|
|
),
|
|
];
|
|
|
|
return array_values(array_filter(
|
|
$secondaryActions,
|
|
static fn (mixed $action): bool => is_array($action)
|
|
&& (! is_string($primaryUrl) || $primaryUrl === '' || ($action['url'] ?? null) !== $primaryUrl),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* key:string,
|
|
* label:string,
|
|
* type:string,
|
|
* url:?string,
|
|
* icon:string,
|
|
* kind:string,
|
|
* action_name:?string,
|
|
* capability:?string,
|
|
* requires_confirmation:bool,
|
|
* audit_event:?string,
|
|
* operation_run_type:?string,
|
|
* disabled_reason:?string
|
|
* }|null
|
|
*/
|
|
private function navigationAction(string $key, string $label, ?string $url): ?array
|
|
{
|
|
if (! is_string($url) || trim($url) === '') {
|
|
return null;
|
|
}
|
|
|
|
return ResolutionAction::fromArray([
|
|
'key' => $key,
|
|
'label' => $label,
|
|
'url' => trim($url),
|
|
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
|
'kind' => 'environment_link',
|
|
], $key, $label);
|
|
}
|
|
|
|
/**
|
|
* @return list<array{type:string,id:int|string}>
|
|
*/
|
|
private function sourceRefs(FindingException $exception): array
|
|
{
|
|
$refs = [
|
|
[
|
|
'type' => 'finding_exception',
|
|
'id' => (int) $exception->getKey(),
|
|
],
|
|
[
|
|
'type' => 'finding',
|
|
'id' => (int) $exception->finding_id,
|
|
],
|
|
];
|
|
|
|
if (is_numeric($exception->current_decision_id)) {
|
|
$refs[] = [
|
|
'type' => 'finding_exception_decision',
|
|
'id' => (int) $exception->current_decision_id,
|
|
];
|
|
}
|
|
|
|
return $refs;
|
|
}
|
|
|
|
/**
|
|
* @return list<array{type:string,id:int|string}>
|
|
*/
|
|
private function evidenceRefs(FindingException $exception): array
|
|
{
|
|
$evidenceReferences = $exception->relationLoaded('evidenceReferences')
|
|
? $exception->evidenceReferences
|
|
: $exception->evidenceReferences()->get();
|
|
|
|
return $evidenceReferences
|
|
->map(fn ($reference): array => [
|
|
'type' => 'finding_exception_evidence_reference',
|
|
'id' => (int) $reference->getKey(),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function technicalDetails(FindingException $exception, ?Finding $finding): array
|
|
{
|
|
$resolvedStatus = $this->resolver->resolveExceptionStatus($exception);
|
|
$resolvedValidity = $finding instanceof Finding
|
|
? (string) ($this->resolver->resolveGovernanceValidity($finding, $exception) ?? $exception->current_validity_state)
|
|
: (string) $exception->current_validity_state;
|
|
$displayValidity = (string) $exception->current_validity_state === FindingException::VALIDITY_MISSING_SUPPORT
|
|
? FindingException::VALIDITY_MISSING_SUPPORT
|
|
: $resolvedValidity;
|
|
$decisionType = $exception->currentDecisionType();
|
|
$missingFields = $this->missingGovernanceFields($exception);
|
|
|
|
return array_filter([
|
|
__('localization.accepted_risk_guidance.detail_environment_label') => (string) ($exception->tenant?->name ?: __('localization.accepted_risk_guidance.detail_not_recorded_value')),
|
|
__('localization.accepted_risk_guidance.detail_status_label') => BadgeRenderer::label(BadgeDomain::FindingExceptionStatus)($resolvedStatus),
|
|
__('localization.accepted_risk_guidance.detail_validity_label') => BadgeRenderer::label(BadgeDomain::FindingRiskGovernanceValidity)($displayValidity),
|
|
__('localization.accepted_risk_guidance.detail_owner_label') => (string) ($exception->owner?->name ?: __('localization.accepted_risk_guidance.detail_missing_value')),
|
|
__('localization.accepted_risk_guidance.detail_review_due_label') => $exception->review_due_at?->toDayDateTimeString() ?? __('localization.accepted_risk_guidance.detail_missing_value'),
|
|
__('localization.accepted_risk_guidance.detail_expires_label') => $exception->expires_at?->toDayDateTimeString() ?? __('localization.accepted_risk_guidance.detail_not_recorded_value'),
|
|
__('localization.accepted_risk_guidance.detail_current_decision_label') => $decisionType !== null
|
|
? Str::headline($decisionType)
|
|
: __('localization.accepted_risk_guidance.detail_not_recorded_value'),
|
|
__('localization.accepted_risk_guidance.detail_request_reason_label') => filled($exception->request_reason)
|
|
? Str::limit((string) $exception->request_reason, 120)
|
|
: __('localization.accepted_risk_guidance.detail_missing_value'),
|
|
__('localization.accepted_risk_guidance.detail_missing_fields_label') => $missingFields !== []
|
|
? implode(', ', $missingFields)
|
|
: null,
|
|
], static fn (mixed $value): bool => is_string($value) && $value !== '');
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function missingGovernanceFields(FindingException $exception): array
|
|
{
|
|
$fields = [];
|
|
|
|
if (! is_numeric($exception->owner_user_id)) {
|
|
$fields[] = __('localization.accepted_risk_guidance.detail_owner_label');
|
|
}
|
|
|
|
if (! filled($exception->request_reason)) {
|
|
$fields[] = __('localization.accepted_risk_guidance.detail_request_reason_label');
|
|
}
|
|
|
|
if (! $exception->review_due_at instanceof \DateTimeInterface) {
|
|
$fields[] = __('localization.accepted_risk_guidance.detail_review_due_label');
|
|
}
|
|
|
|
return $fields;
|
|
}
|
|
}
|