TenantAtlas/app/Services/Findings/FindingExceptionService.php
ahmido b1e1e06861 feat: implement finding risk acceptance lifecycle (#184)
## Summary
- add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support
- add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament
- add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity

## Validation
- vendor/bin/sail bin pint --dirty --format agent
- CI=1 vendor/bin/sail artisan test --compact
- manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility

## Notes
- Filament implementation remains on v5 with Livewire v4-compatible surfaces
- canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php
- finding exceptions stay out of global search in this rollout

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #184
2026-03-20 01:07:55 +00:00

917 lines
35 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\Finding;
use App\Models\FindingException;
use App\Models\FindingExceptionDecision;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final class FindingExceptionService
{
public function __construct(
private readonly CapabilityResolver $capabilityResolver,
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
private readonly FindingWorkflowService $findingWorkflowService,
private readonly FindingRiskGovernanceResolver $governanceResolver,
private readonly AuditLogger $auditLogger,
) {}
/**
* @param array{
* owner_user_id?: mixed,
* request_reason?: mixed,
* review_due_at?: mixed,
* expires_at?: mixed,
* evidence_references?: mixed
* } $payload
*/
public function request(Finding $finding, Tenant $tenant, User $actor, array $payload): FindingException
{
$this->authorizeRequest($finding, $tenant, $actor);
$ownerUserId = $this->validatedTenantMemberId(
tenant: $tenant,
userId: $payload['owner_user_id'] ?? null,
field: 'owner_user_id',
required: true,
);
$requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
$reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
$evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
$requestedAt = CarbonImmutable::now();
/** @var FindingException $exception */
$exception = DB::transaction(function () use ($finding, $tenant, $actor, $ownerUserId, $requestReason, $reviewDueAt, $expiresAt, $evidenceReferences, $requestedAt): FindingException {
$exception = FindingException::query()
->where('finding_id', (int) $finding->getKey())
->lockForUpdate()
->first();
if ($exception instanceof FindingException && $exception->isPending()) {
throw new InvalidArgumentException('An exception request is already pending for this finding.');
}
if ($exception instanceof FindingException && $exception->isActiveLike()) {
throw new InvalidArgumentException('This finding already has an active exception.');
}
$exception ??= new FindingException([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'finding_id' => (int) $finding->getKey(),
]);
$before = $this->exceptionSnapshot($exception);
$exception->fill([
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => $ownerUserId,
'approved_by_user_id' => null,
'status' => FindingException::STATUS_PENDING,
'current_validity_state' => FindingException::VALIDITY_MISSING_SUPPORT,
'request_reason' => $requestReason,
'approval_reason' => null,
'rejection_reason' => null,
'revocation_reason' => null,
'requested_at' => $requestedAt,
'approved_at' => null,
'rejected_at' => null,
'revoked_at' => null,
'effective_from' => null,
'expires_at' => $expiresAt,
'review_due_at' => $reviewDueAt,
'evidence_summary' => $this->evidenceSummary($evidenceReferences),
]);
$exception->save();
$this->replaceEvidenceReferences($exception, $evidenceReferences);
$decision = $exception->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'reason' => $requestReason,
'expires_at' => $expiresAt,
'metadata' => [
'review_due_at' => $reviewDueAt->toIso8601String(),
'evidence_reference_count' => count($evidenceReferences),
],
'decided_at' => $requestedAt,
]);
$exception->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$after = $this->exceptionSnapshot($exception->fresh($this->exceptionRelationships()) ?? $exception);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRequested,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $exception->getKey(),
targetLabel: 'Finding exception #'.$exception->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REQUESTED,
'before' => $before,
'after' => $after,
],
],
);
return $exception;
});
return $this->governanceResolver->syncExceptionState(
$exception->fresh($this->exceptionRelationships()) ?? $exception,
);
}
/**
* @param array{
* effective_from?: mixed,
* expires_at?: mixed,
* approval_reason?: mixed
* } $payload
*/
public function approve(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$workspace = $this->workspaceForTenant($tenant);
$this->authorizeApproval($exception, $tenant, $workspace, $actor);
$effectiveFrom = $this->validatedDate($payload['effective_from'] ?? null, 'effective_from');
$expiresAt = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $effectiveFrom, required: true);
$approvalReason = $this->validatedOptionalReason($payload['approval_reason'] ?? null, 'approval_reason');
$approvedAt = CarbonImmutable::now();
/** @var FindingException $approvedException */
$approvedException = DB::transaction(function () use ($exception, $tenant, $actor, $effectiveFrom, $expiresAt, $approvalReason, $approvedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['finding', 'tenant', 'requester', 'currentDecision'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->isPending()) {
throw new InvalidArgumentException('Only pending exception requests can be approved.');
}
if ((int) $lockedException->requested_by_user_id === (int) $actor->getKey()) {
throw new InvalidArgumentException('Requesters cannot approve their own exception requests.');
}
$isRenewalApproval = $lockedException->isPendingRenewal();
$before = $this->exceptionSnapshot($lockedException);
$lockedException->fill([
'status' => FindingException::STATUS_ACTIVE,
'current_validity_state' => FindingException::VALIDITY_VALID,
'approved_by_user_id' => (int) $actor->getKey(),
'approval_reason' => $approvalReason,
'approved_at' => $approvedAt,
'effective_from' => $effectiveFrom,
'expires_at' => $expiresAt,
'rejection_reason' => null,
'rejected_at' => null,
'revocation_reason' => null,
]);
$lockedException->save();
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => $isRenewalApproval
? FindingExceptionDecision::TYPE_RENEWED
: FindingExceptionDecision::TYPE_APPROVED,
'reason' => $approvalReason,
'effective_from' => $effectiveFrom,
'expires_at' => $expiresAt,
'metadata' => [
'request_type' => $isRenewalApproval ? 'renewal' : 'initial',
],
'decided_at' => $approvedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$finding = $lockedException->finding;
if (! $finding instanceof Finding) {
throw new InvalidArgumentException('The linked finding could not be resolved.');
}
if (! $isRenewalApproval) {
$this->findingWorkflowService->riskAcceptFromException(
finding: $finding,
tenant: $tenant,
actor: $actor,
reason: $this->findingRiskAcceptedReason($lockedException, $approvalReason),
);
}
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: $isRenewalApproval
? AuditActionId::FindingExceptionRenewed
: AuditActionId::FindingExceptionApproved,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $finding->getKey(),
'decision_type' => $isRenewalApproval
? FindingExceptionDecision::TYPE_RENEWED
: FindingExceptionDecision::TYPE_APPROVED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $approvedException;
}
/**
* @param array{
* rejection_reason?: mixed
* } $payload
*/
public function reject(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$workspace = $this->workspaceForTenant($tenant);
$this->authorizeApproval($exception, $tenant, $workspace, $actor);
$rejectionReason = $this->validatedReason($payload['rejection_reason'] ?? null, 'rejection_reason');
$rejectedAt = CarbonImmutable::now();
/** @var FindingException $rejectedException */
$rejectedException = DB::transaction(function () use ($exception, $tenant, $actor, $rejectionReason, $rejectedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['finding', 'currentDecision'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->isPending()) {
throw new InvalidArgumentException('Only pending exception requests can be rejected.');
}
$isRenewalRejection = $lockedException->isPendingRenewal();
$before = $this->exceptionSnapshot($lockedException);
if ($isRenewalRejection) {
$lockedException->fill([
'status' => FindingException::STATUS_ACTIVE,
'rejection_reason' => $rejectionReason,
'rejected_at' => $rejectedAt,
'review_due_at' => $this->metadataDate($lockedException, 'previous_review_due_at') ?? $lockedException->review_due_at,
]);
} else {
$lockedException->fill([
'status' => FindingException::STATUS_REJECTED,
'current_validity_state' => FindingException::VALIDITY_REJECTED,
'rejection_reason' => $rejectionReason,
'rejected_at' => $rejectedAt,
'approved_by_user_id' => null,
'approved_at' => null,
'approval_reason' => null,
'effective_from' => null,
]);
}
$lockedException->save();
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
'reason' => $rejectionReason,
'metadata' => [
'request_type' => $isRenewalRejection ? 'renewal' : 'initial',
],
'decided_at' => $rejectedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRejected,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $resolvedException->finding_id,
'decision_type' => FindingExceptionDecision::TYPE_REJECTED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $rejectedException;
}
/**
* @param array{
* owner_user_id?: mixed,
* request_reason?: mixed,
* review_due_at?: mixed,
* expires_at?: mixed,
* evidence_references?: mixed
* } $payload
*/
public function renew(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$this->authorizeManagement($exception, $tenant, $actor);
$requestReason = $this->validatedReason($payload['request_reason'] ?? null, 'request_reason');
$reviewDueAt = $this->validatedFutureDate($payload['review_due_at'] ?? null, 'review_due_at');
$requestedExpiry = $this->validatedOptionalExpiry($payload['expires_at'] ?? null, $reviewDueAt);
$evidenceReferences = $this->validatedEvidenceReferences($payload['evidence_references'] ?? []);
$requestedAt = CarbonImmutable::now();
/** @var FindingException $renewedException */
$renewedException = DB::transaction(function () use ($exception, $tenant, $actor, $payload, $requestReason, $reviewDueAt, $requestedExpiry, $evidenceReferences, $requestedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['currentDecision', 'finding'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->canBeRenewed()) {
throw new InvalidArgumentException('Only active, expiring, or expired exceptions can be renewed.');
}
$ownerUserId = array_key_exists('owner_user_id', $payload)
? $this->validatedTenantMemberId($tenant, $payload['owner_user_id'], 'owner_user_id')
: (is_numeric($lockedException->owner_user_id) ? (int) $lockedException->owner_user_id : null);
$before = $this->exceptionSnapshot($lockedException);
$lockedException->fill([
'requested_by_user_id' => (int) $actor->getKey(),
'owner_user_id' => $ownerUserId,
'status' => FindingException::STATUS_PENDING,
'request_reason' => $requestReason,
'requested_at' => $requestedAt,
'review_due_at' => $reviewDueAt,
'rejection_reason' => null,
'rejected_at' => null,
'revocation_reason' => null,
'evidence_summary' => $this->evidenceSummary($evidenceReferences),
]);
$lockedException->save();
$this->replaceEvidenceReferences($lockedException, $evidenceReferences);
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
'reason' => $requestReason,
'expires_at' => $requestedExpiry,
'metadata' => [
'review_due_at' => $reviewDueAt->toIso8601String(),
'requested_expires_at' => $requestedExpiry?->toIso8601String(),
'previous_review_due_at' => $lockedException->getOriginal('review_due_at'),
'previous_expires_at' => $lockedException->getOriginal('expires_at'),
'evidence_reference_count' => count($evidenceReferences),
],
'decided_at' => $requestedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRenewalRequested,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $resolvedException->finding_id,
'decision_type' => FindingExceptionDecision::TYPE_RENEWAL_REQUESTED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $renewedException;
}
/**
* @param array{
* revocation_reason?: mixed
* } $payload
*/
public function revoke(FindingException $exception, User $actor, array $payload): FindingException
{
$tenant = $this->tenantForException($exception);
$this->authorizeManagement($exception, $tenant, $actor);
$revocationReason = $this->validatedReason($payload['revocation_reason'] ?? null, 'revocation_reason');
$revokedAt = CarbonImmutable::now();
/** @var FindingException $revokedException */
$revokedException = DB::transaction(function () use ($exception, $tenant, $actor, $revocationReason, $revokedAt): FindingException {
/** @var FindingException $lockedException */
$lockedException = FindingException::query()
->with(['currentDecision', 'finding'])
->whereKey((int) $exception->getKey())
->lockForUpdate()
->firstOrFail();
if (! $lockedException->canBeRevoked()) {
throw new InvalidArgumentException('Only active or pending-renewal exceptions can be revoked.');
}
$before = $this->exceptionSnapshot($lockedException);
$lockedException->fill([
'status' => FindingException::STATUS_REVOKED,
'current_validity_state' => FindingException::VALIDITY_REVOKED,
'revocation_reason' => $revocationReason,
'revoked_at' => $revokedAt,
]);
$lockedException->save();
$decision = $lockedException->decisions()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $actor->getKey(),
'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
'reason' => $revocationReason,
'metadata' => [],
'decided_at' => $revokedAt,
]);
$lockedException->forceFill([
'current_decision_id' => (int) $decision->getKey(),
])->save();
$resolvedException = $this->governanceResolver->syncExceptionState(
$lockedException->fresh($this->exceptionRelationships()) ?? $lockedException,
);
$after = $this->exceptionSnapshot($resolvedException);
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::FindingExceptionRevoked,
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
resourceType: 'finding_exception',
resourceId: (string) $resolvedException->getKey(),
targetLabel: 'Finding exception #'.$resolvedException->getKey(),
context: [
'metadata' => [
'finding_id' => (int) $resolvedException->finding_id,
'decision_type' => FindingExceptionDecision::TYPE_REVOKED,
'before' => $before,
'after' => $after,
],
],
);
return $resolvedException;
});
return $revokedException;
}
private function authorizeRequest(Finding $finding, Tenant $tenant, User $actor): void
{
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
$this->assertFindingOwnedByTenant($finding, $tenant);
if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
return;
}
throw new AuthorizationException('Missing capability for exception request.');
}
private function authorizeApproval(FindingException $exception, Tenant $tenant, Workspace $workspace, User $actor): void
{
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) {
throw new NotFoundHttpException;
}
if ((int) $exception->workspace_id !== (int) $workspace->getKey() || (int) $exception->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ($this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::FINDING_EXCEPTION_APPROVE)) {
return;
}
throw new AuthorizationException('Missing capability for exception approval.');
}
private function authorizeManagement(FindingException $exception, Tenant $tenant, User $actor): void
{
if (! $actor->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if ((int) $exception->workspace_id !== (int) $tenant->workspace_id || (int) $exception->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ($this->capabilityResolver->can($actor, $tenant, Capabilities::FINDING_EXCEPTION_MANAGE)) {
return;
}
throw new AuthorizationException('Missing capability for exception management.');
}
private function tenantForException(FindingException $exception): Tenant
{
$tenant = $exception->tenant;
if (! $tenant instanceof Tenant) {
$tenant = Tenant::query()->findOrFail((int) $exception->tenant_id);
}
return $tenant;
}
private function workspaceForTenant(Tenant $tenant): Workspace
{
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
$workspace = Workspace::query()->findOrFail((int) $tenant->workspace_id);
}
return $workspace;
}
private function assertFindingOwnedByTenant(Finding $finding, Tenant $tenant): void
{
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
throw new NotFoundHttpException;
}
if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) {
throw new NotFoundHttpException;
}
}
private function validatedTenantMemberId(Tenant $tenant, mixed $userId, string $field, bool $required = false): ?int
{
if ($userId === null || $userId === '') {
if ($required) {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
return null;
}
if (! is_numeric($userId) || (int) $userId <= 0) {
throw new InvalidArgumentException(sprintf('%s must reference a valid user.', $field));
}
$resolvedUserId = (int) $userId;
$isMember = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', $resolvedUserId)
->exists();
if (! $isMember) {
throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field));
}
return $resolvedUserId;
}
private function validatedReason(mixed $reason, string $field): string
{
if (! is_string($reason)) {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
$resolved = trim($reason);
if ($resolved === '') {
throw new InvalidArgumentException(sprintf('%s is required.', $field));
}
if (mb_strlen($resolved) > 2000) {
throw new InvalidArgumentException(sprintf('%s must be at most 2000 characters.', $field));
}
return $resolved;
}
private function validatedOptionalReason(mixed $reason, string $field): ?string
{
if ($reason === null || $reason === '') {
return null;
}
return $this->validatedReason($reason, $field);
}
private function validatedDate(mixed $value, string $field): CarbonImmutable
{
try {
return CarbonImmutable::parse((string) $value);
} catch (\Throwable) {
throw new InvalidArgumentException(sprintf('%s must be a valid date-time.', $field));
}
}
private function validatedFutureDate(mixed $value, string $field): CarbonImmutable
{
$date = $this->validatedDate($value, $field);
if ($date->lessThanOrEqualTo(CarbonImmutable::now())) {
throw new InvalidArgumentException(sprintf('%s must be in the future.', $field));
}
return $date;
}
private function validatedOptionalExpiry(mixed $value, CarbonImmutable $minimum, bool $required = false): ?CarbonImmutable
{
if ($value === null || $value === '') {
if ($required) {
throw new InvalidArgumentException('expires_at is required.');
}
return null;
}
$expiresAt = $this->validatedDate($value, 'expires_at');
if ($expiresAt->lessThanOrEqualTo($minimum)) {
throw new InvalidArgumentException('expires_at must be after the related review or effective date.');
}
return $expiresAt;
}
/**
* @return list<array{
* source_type: string,
* source_id: ?string,
* source_fingerprint: ?string,
* label: string,
* measured_at: ?CarbonImmutable,
* summary_payload: array<string, mixed>
* }>
*/
private function validatedEvidenceReferences(mixed $references): array
{
if (! is_array($references)) {
return [];
}
$resolved = [];
foreach ($references as $reference) {
if (! is_array($reference)) {
continue;
}
$sourceType = trim((string) ($reference['source_type'] ?? ''));
$label = trim((string) ($reference['label'] ?? ''));
if ($sourceType === '' || $label === '') {
continue;
}
$measuredAt = null;
if (($reference['measured_at'] ?? null) !== null && (string) $reference['measured_at'] !== '') {
$measuredAt = $this->validatedDate($reference['measured_at'], 'measured_at');
}
$resolved[] = [
'source_type' => $sourceType,
'source_id' => filled($reference['source_id'] ?? null) ? trim((string) $reference['source_id']) : null,
'source_fingerprint' => filled($reference['source_fingerprint'] ?? null) ? trim((string) $reference['source_fingerprint']) : null,
'label' => mb_substr($label, 0, 255),
'measured_at' => $measuredAt,
'summary_payload' => is_array($reference['summary_payload'] ?? null) ? $reference['summary_payload'] : [],
];
}
return $resolved;
}
/**
* @param list<array{
* source_type: string,
* source_id: ?string,
* source_fingerprint: ?string,
* label: string,
* measured_at: ?CarbonImmutable,
* summary_payload: array<string, mixed>
* }> $references
*/
private function replaceEvidenceReferences(FindingException $exception, array $references): void
{
$exception->evidenceReferences()->delete();
foreach ($references as $reference) {
$exception->evidenceReferences()->create([
'workspace_id' => (int) $exception->workspace_id,
'tenant_id' => (int) $exception->tenant_id,
'source_type' => $reference['source_type'],
'source_id' => $reference['source_id'],
'source_fingerprint' => $reference['source_fingerprint'],
'label' => $reference['label'],
'measured_at' => $reference['measured_at'],
'summary_payload' => $reference['summary_payload'],
]);
}
}
/**
* @param list<array{
* source_type: string,
* source_id: ?string,
* source_fingerprint: ?string,
* label: string,
* measured_at: ?CarbonImmutable,
* summary_payload: array<string, mixed>
* }> $references
* @return array<string, mixed>
*/
private function evidenceSummary(array $references): array
{
return [
'reference_count' => count($references),
'labels' => array_values(array_map(
static fn (array $reference): string => $reference['label'],
array_slice($references, 0, 5),
)),
];
}
private function findingRiskAcceptedReason(FindingException $exception, ?string $approvalReason): string
{
if (is_string($approvalReason) && $approvalReason !== '') {
return mb_substr($approvalReason, 0, 255);
}
return 'Governed by approved exception #'.$exception->getKey();
}
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
{
$currentDecision = $exception->relationLoaded('currentDecision')
? $exception->currentDecision
: $exception->currentDecision()->first();
if (! $currentDecision instanceof FindingExceptionDecision) {
return null;
}
$value = $currentDecision->metadata[$key] ?? null;
if (! is_string($value) || trim($value) === '') {
return null;
}
return CarbonImmutable::parse($value);
}
/**
* @return array<string, mixed>
*/
private function exceptionSnapshot(FindingException $exception): array
{
return [
'status' => $exception->status,
'current_validity_state' => $exception->current_validity_state,
'current_decision_type' => $exception->currentDecisionType(),
'finding_id' => $exception->finding_id,
'requested_by_user_id' => $exception->requested_by_user_id,
'owner_user_id' => $exception->owner_user_id,
'approved_by_user_id' => $exception->approved_by_user_id,
'requested_at' => $exception->requested_at?->toIso8601String(),
'approved_at' => $exception->approved_at?->toIso8601String(),
'rejected_at' => $exception->rejected_at?->toIso8601String(),
'revoked_at' => $exception->revoked_at?->toIso8601String(),
'effective_from' => $exception->effective_from?->toIso8601String(),
'expires_at' => $exception->expires_at?->toIso8601String(),
'review_due_at' => $exception->review_due_at?->toIso8601String(),
'request_reason' => $exception->request_reason,
'approval_reason' => $exception->approval_reason,
'rejection_reason' => $exception->rejection_reason,
'revocation_reason' => $exception->revocation_reason,
];
}
/**
* @return array<int, string|array<int|string, mixed>>
*/
private function exceptionRelationships(): array
{
return [
'finding',
'tenant',
'requester',
'owner',
'approver',
'currentDecision',
'decisions.actor',
'evidenceReferences',
];
}
}