authorize($finding, $tenant, $actor, [ Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, ]); $currentStatus = (string) $finding->status; if (! in_array($currentStatus, [ Finding::STATUS_NEW, Finding::STATUS_REOPENED, Finding::STATUS_ACKNOWLEDGED, ], true)) { throw new InvalidArgumentException('Finding cannot be triaged from the current status.'); } $now = CarbonImmutable::now(); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.triaged', context: [ 'metadata' => [ 'triaged_at' => $now->toIso8601String(), ], ], mutate: function (Finding $record) use ($now): void { $record->status = Finding::STATUS_TRIAGED; $record->triaged_at = $record->triaged_at ?? $now; }, ); } public function startProgress(Finding $finding, Tenant $tenant, User $actor): Finding { $this->authorize($finding, $tenant, $actor, [ Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, ]); if (! in_array((string) $finding->status, [Finding::STATUS_TRIAGED, Finding::STATUS_ACKNOWLEDGED], true)) { throw new InvalidArgumentException('Finding cannot be moved to in-progress from the current status.'); } $now = CarbonImmutable::now(); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.in_progress', context: [ 'metadata' => [ 'in_progress_at' => $now->toIso8601String(), ], ], mutate: function (Finding $record) use ($now): void { $record->status = Finding::STATUS_IN_PROGRESS; $record->in_progress_at = $record->in_progress_at ?? $now; $record->triaged_at = $record->triaged_at ?? $now; }, ); } public function assign( Finding $finding, Tenant $tenant, User $actor, ?int $assigneeUserId = null, ?int $ownerUserId = null, ): Finding { $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]); if (! $finding->hasOpenStatus()) { throw new InvalidArgumentException('Only open findings can be assigned.'); } $this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id'); $this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id'); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.assigned', context: [ 'metadata' => [ 'assignee_user_id' => $assigneeUserId, 'owner_user_id' => $ownerUserId, ], ], mutate: function (Finding $record) use ($assigneeUserId, $ownerUserId): void { $record->assignee_user_id = $assigneeUserId; $record->owner_user_id = $ownerUserId; }, ); } public function resolve(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RESOLVE]); if (! $finding->hasOpenStatus()) { throw new InvalidArgumentException('Only open findings can be resolved.'); } $reason = $this->validatedReason($reason, 'resolved_reason'); $now = CarbonImmutable::now(); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.resolved', context: [ 'metadata' => [ 'resolved_reason' => $reason, 'resolved_at' => $now->toIso8601String(), ], ], mutate: function (Finding $record) use ($reason, $now): void { $record->status = Finding::STATUS_RESOLVED; $record->resolved_reason = $reason; $record->resolved_at = $now; }, ); } public function close(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]); $reason = $this->validatedReason($reason, 'closed_reason'); $now = CarbonImmutable::now(); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.closed', context: [ 'metadata' => [ 'closed_reason' => $reason, 'closed_at' => $now->toIso8601String(), ], ], mutate: function (Finding $record) use ($reason, $now, $actor): void { $record->status = Finding::STATUS_CLOSED; $record->closed_reason = $reason; $record->closed_at = $now; $record->closed_by_user_id = (int) $actor->getKey(); }, ); } public function riskAccept(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { $this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_RISK_ACCEPT]); $reason = $this->validatedReason($reason, 'closed_reason'); $now = CarbonImmutable::now(); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.risk_accepted', context: [ 'metadata' => [ 'closed_reason' => $reason, 'closed_at' => $now->toIso8601String(), ], ], mutate: function (Finding $record) use ($reason, $now, $actor): void { $record->status = Finding::STATUS_RISK_ACCEPTED; $record->closed_reason = $reason; $record->closed_at = $now; $record->closed_by_user_id = (int) $actor->getKey(); }, ); } public function reopen(Finding $finding, Tenant $tenant, User $actor): Finding { $this->authorize($finding, $tenant, $actor, [ Capabilities::TENANT_FINDINGS_TRIAGE, Capabilities::TENANT_FINDINGS_ACKNOWLEDGE, ]); if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { throw new InvalidArgumentException('Only terminal findings can be reopened.'); } $now = CarbonImmutable::now(); $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: 'finding.reopened', context: [ 'metadata' => [ 'reopened_at' => $now->toIso8601String(), 'sla_days' => $slaDays, 'due_at' => $dueAt->toIso8601String(), ], ], mutate: function (Finding $record) use ($now, $slaDays, $dueAt): void { $record->status = Finding::STATUS_REOPENED; $record->reopened_at = $now; $record->resolved_at = null; $record->resolved_reason = null; $record->closed_at = null; $record->closed_reason = null; $record->closed_by_user_id = null; $record->sla_days = $slaDays; $record->due_at = $dueAt; }, ); } /** * @param array $capabilities */ private function authorize(Finding $finding, Tenant $tenant, User $actor, array $capabilities): void { if (! $actor->canAccessTenant($tenant)) { throw new NotFoundHttpException; } if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { throw new NotFoundHttpException; } if ((int) $finding->workspace_id !== (int) $tenant->workspace_id) { throw new NotFoundHttpException; } foreach ($capabilities as $capability) { if ($this->capabilityResolver->can($actor, $tenant, $capability)) { return; } } throw new AuthorizationException('Missing capability for finding workflow action.'); } private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $field): void { if ($userId === null) { return; } if ($userId <= 0) { throw new InvalidArgumentException(sprintf('%s must be a positive user id.', $field)); } $isMember = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('user_id', $userId) ->exists(); if (! $isMember) { throw new InvalidArgumentException(sprintf('%s must reference a current tenant member.', $field)); } } private function validatedReason(string $reason, string $field): string { $reason = trim($reason); if ($reason === '') { throw new InvalidArgumentException(sprintf('%s is required.', $field)); } if (mb_strlen($reason) > 255) { throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field)); } return $reason; } /** * @param array $context */ private function mutateAndAudit( Finding $finding, Tenant $tenant, User $actor, string $action, array $context, callable $mutate, ): Finding { $before = $this->auditSnapshot($finding); DB::transaction(function () use ($finding, $mutate): void { $mutate($finding); $finding->save(); }); $finding->refresh(); $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; $metadata = array_merge($metadata, [ 'finding_id' => (int) $finding->getKey(), 'before_status' => $before['status'] ?? null, 'after_status' => $finding->status, 'before' => $before, 'after' => $this->auditSnapshot($finding), ]); $this->auditLogger->log( tenant: $tenant, action: $action, actorId: (int) $actor->getKey(), actorEmail: $actor->email, actorName: $actor->name, resourceType: 'finding', resourceId: (string) $finding->getKey(), context: ['metadata' => $metadata], ); return $finding; } /** * @return array */ private function auditSnapshot(Finding $finding): array { return [ 'status' => $finding->status, 'severity' => $finding->severity, 'due_at' => $finding->due_at?->toIso8601String(), 'sla_days' => $finding->sla_days, 'assignee_user_id' => $finding->assignee_user_id, 'owner_user_id' => $finding->owner_user_id, 'triaged_at' => $finding->triaged_at?->toIso8601String(), 'in_progress_at' => $finding->in_progress_at?->toIso8601String(), 'reopened_at' => $finding->reopened_at?->toIso8601String(), 'resolved_at' => $finding->resolved_at?->toIso8601String(), 'resolved_reason' => $finding->resolved_reason, 'closed_at' => $finding->closed_at?->toIso8601String(), 'closed_reason' => $finding->closed_reason, 'closed_by_user_id' => $finding->closed_by_user_id, ]; } }