TenantAtlas/app/Services/Findings/FindingRiskGovernanceResolver.php
2026-03-20 02:05:50 +01:00

231 lines
9.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use Carbon\CarbonImmutable;
use Illuminate\Support\Carbon;
final class FindingRiskGovernanceResolver
{
public function resolveExceptionStatus(FindingException $exception, ?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::instance(now());
$status = (string) $exception->status;
if (in_array($status, [
FindingException::STATUS_REJECTED,
FindingException::STATUS_REVOKED,
FindingException::STATUS_SUPERSEDED,
], true)) {
return $status;
}
if ($status === FindingException::STATUS_PENDING) {
return FindingException::STATUS_PENDING;
}
$expiresAt = $exception->expires_at instanceof Carbon
? CarbonImmutable::instance($exception->expires_at)
: null;
if ($expiresAt instanceof CarbonImmutable && $expiresAt->lessThanOrEqualTo($now)) {
return FindingException::STATUS_EXPIRED;
}
if ($this->isExpiring($exception, $now)) {
return FindingException::STATUS_EXPIRING;
}
return FindingException::STATUS_ACTIVE;
}
public function resolveValidityState(FindingException $exception, ?CarbonImmutable $now = null): string
{
if ($exception->isPendingRenewal()) {
return $this->resolveApprovedValidityState($exception, $now);
}
return match ($this->resolveExceptionStatus($exception, $now)) {
FindingException::STATUS_ACTIVE => FindingException::VALIDITY_VALID,
FindingException::STATUS_EXPIRING => FindingException::VALIDITY_EXPIRING,
FindingException::STATUS_EXPIRED => FindingException::VALIDITY_EXPIRED,
FindingException::STATUS_REVOKED => FindingException::VALIDITY_REVOKED,
FindingException::STATUS_REJECTED => FindingException::VALIDITY_REJECTED,
default => FindingException::VALIDITY_MISSING_SUPPORT,
};
}
public function resolveFindingState(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): string
{
$exception ??= $finding->relationLoaded('findingException')
? $finding->findingException
: $finding->findingException()->first();
$findingIsRiskAccepted = $finding->isRiskAccepted();
if (! $exception instanceof FindingException) {
return $findingIsRiskAccepted
? 'risk_accepted_without_valid_exception'
: 'ungoverned';
}
if (! $findingIsRiskAccepted) {
return $exception->isPending()
? 'pending_exception'
: 'ungoverned';
}
if ($exception->isPendingRenewal()) {
return match ($this->resolveApprovedValidityState($exception, $now)) {
FindingException::VALIDITY_VALID => 'valid_exception',
FindingException::VALIDITY_EXPIRING => 'expiring_exception',
FindingException::VALIDITY_EXPIRED => 'expired_exception',
default => 'pending_exception',
};
}
return match ($this->resolveExceptionStatus($exception, $now)) {
FindingException::STATUS_PENDING => 'pending_exception',
FindingException::STATUS_ACTIVE => 'valid_exception',
FindingException::STATUS_EXPIRING => 'expiring_exception',
FindingException::STATUS_EXPIRED => 'expired_exception',
FindingException::STATUS_REVOKED => 'revoked_exception',
FindingException::STATUS_REJECTED => 'rejected_exception',
default => $findingIsRiskAccepted
? 'risk_accepted_without_valid_exception'
: 'ungoverned',
};
}
public function isValidGovernedAcceptedRisk(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): bool
{
return in_array($this->resolveFindingState($finding, $exception, $now), [
'valid_exception',
'expiring_exception',
], true);
}
public function resolveWarningMessage(Finding $finding, ?FindingException $exception = null, ?CarbonImmutable $now = null): ?string
{
$exception ??= $finding->relationLoaded('findingException')
? $finding->findingException
: $finding->findingException()->first();
if (! $exception instanceof FindingException) {
return $finding->isRiskAccepted()
? 'This finding is marked as accepted risk without a valid exception record.'
: null;
}
$exceptionStatus = $exception->isPendingRenewal()
? match ($this->resolveApprovedValidityState($exception, $now)) {
FindingException::VALIDITY_EXPIRED => FindingException::STATUS_EXPIRED,
FindingException::VALIDITY_EXPIRING => FindingException::STATUS_EXPIRING,
FindingException::VALIDITY_VALID => FindingException::STATUS_ACTIVE,
default => FindingException::STATUS_PENDING,
}
: $this->resolveExceptionStatus($exception, $now);
if ($finding->isRiskAccepted()) {
return match ($this->resolveFindingState($finding, $exception, $now)) {
'risk_accepted_without_valid_exception' => 'This finding is marked as accepted risk without a valid exception record.',
'expired_exception' => 'The linked exception has expired and no longer governs accepted risk.',
'revoked_exception' => 'The linked exception was revoked and no longer governs accepted risk.',
'rejected_exception' => 'The linked exception was rejected and does not govern accepted risk.',
default => null,
};
}
if ($exception->requiresFreshDecisionForFinding($finding)) {
return 'This finding changed after the earlier exception decision; a fresh decision is required.';
}
return match ($exceptionStatus) {
FindingException::STATUS_EXPIRED => 'The linked exception has expired and no longer governs accepted risk.',
FindingException::STATUS_REVOKED => 'The linked exception was revoked and no longer governs accepted risk.',
FindingException::STATUS_REJECTED => 'The linked exception was rejected and does not govern accepted risk.',
default => null,
};
}
public function syncExceptionState(FindingException $exception, ?CarbonImmutable $now = null): FindingException
{
$resolvedStatus = $this->resolveExceptionStatus($exception, $now);
$resolvedValidityState = $this->resolveValidityState($exception, $now);
if ((string) $exception->status === $resolvedStatus && (string) $exception->current_validity_state === $resolvedValidityState) {
return $exception;
}
$exception->forceFill([
'status' => $resolvedStatus,
'current_validity_state' => $resolvedValidityState,
])->save();
return $exception->refresh();
}
private function resolveApprovedValidityState(FindingException $exception, ?CarbonImmutable $now = null): string
{
$now ??= CarbonImmutable::instance(now());
$expiresAt = $this->renewalAwareDate(
$exception,
'previous_expires_at',
$exception->expires_at,
);
if ($expiresAt instanceof CarbonImmutable && $expiresAt->lessThanOrEqualTo($now)) {
return FindingException::VALIDITY_EXPIRED;
}
if ($this->isExpiring($exception, $now, renewalAware: true)) {
return FindingException::VALIDITY_EXPIRING;
}
return FindingException::VALIDITY_VALID;
}
private function isExpiring(FindingException $exception, CarbonImmutable $now, bool $renewalAware = false): bool
{
$reviewDueAt = $renewalAware
? $this->renewalAwareDate($exception, 'previous_review_due_at', $exception->review_due_at)
: ($exception->review_due_at instanceof Carbon ? CarbonImmutable::instance($exception->review_due_at) : null);
if ($reviewDueAt instanceof CarbonImmutable && $reviewDueAt->lessThanOrEqualTo($now)) {
return true;
}
$expiresAt = $renewalAware
? $this->renewalAwareDate($exception, 'previous_expires_at', $exception->expires_at)
: ($exception->expires_at instanceof Carbon ? CarbonImmutable::instance($exception->expires_at) : null);
if (! $expiresAt instanceof CarbonImmutable) {
return false;
}
return $expiresAt->lessThanOrEqualTo($now->addDays(7));
}
private function renewalAwareDate(FindingException $exception, string $metadataKey, mixed $fallback): ?CarbonImmutable
{
$currentDecision = $exception->relationLoaded('currentDecision')
? $exception->currentDecision
: $exception->currentDecision()->first();
if ($currentDecision instanceof FindingExceptionDecision && is_string($currentDecision->metadata[$metadataKey] ?? null)) {
return CarbonImmutable::parse((string) $currentDecision->metadata[$metadataKey]);
}
return $fallback instanceof Carbon
? CarbonImmutable::instance($fallback)
: null;
}
}