## 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
917 lines
35 KiB
PHP
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',
|
|
];
|
|
}
|
|
}
|