TenantAtlas/apps/platform/app/Support/ResolutionGuidance/Adapters/AcceptedRiskResolutionAdapter.php
ahmido a9c54205bf feat: finding exceptions accepted risk resolution guidance v1 (spec 354) (#425)
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
2026-06-05 02:20:46 +00:00

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;
}
}