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: AuditActionId::FindingTriaged, 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: AuditActionId::FindingInProgress, 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: AuditActionId::FindingAssigned, 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: AuditActionId::FindingResolved, 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: AuditActionId::FindingClosed, 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]); return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason); } public function riskAcceptFromException(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { $this->assertFindingOwnedByTenant($finding, $tenant); return $this->riskAcceptWithoutAuthorization($finding, $tenant, $actor, $reason); } private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant, User $actor, string $reason): Finding { if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RISK_ACCEPTED) { throw new InvalidArgumentException('Only open findings can be marked as risk accepted.'); } $reason = $this->validatedReason($reason, 'closed_reason'); $now = CarbonImmutable::now(); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: $actor, action: AuditActionId::FindingRiskAccepted, 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: AuditActionId::FindingReopened, 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; }, ); } public function resolveBySystem( Finding $finding, Tenant $tenant, string $reason, CarbonImmutable $resolvedAt, ?int $operationRunId = null, ?callable $mutate = null, ): Finding { $this->assertFindingOwnedByTenant($finding, $tenant); if (! $finding->hasOpenStatus()) { throw new InvalidArgumentException('Only open findings can be resolved.'); } $reason = $this->validatedReason($reason, 'resolved_reason'); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: null, action: AuditActionId::FindingResolved, context: [ 'metadata' => [ 'resolved_reason' => $reason, 'resolved_at' => $resolvedAt->toIso8601String(), 'system_origin' => true, ], ], mutate: function (Finding $record) use ($mutate, $reason, $resolvedAt): void { if ($mutate !== null) { $mutate($record); } $record->status = Finding::STATUS_RESOLVED; $record->resolved_reason = $reason; $record->resolved_at = $resolvedAt; }, actorType: AuditActorType::System, operationRunId: $operationRunId, ); } public function reopenBySystem( Finding $finding, Tenant $tenant, CarbonImmutable $reopenedAt, ?int $operationRunId = null, ?callable $mutate = null, ): Finding { $this->assertFindingOwnedByTenant($finding, $tenant); if (! in_array((string) $finding->status, Finding::terminalStatuses(), true)) { throw new InvalidArgumentException('Only terminal findings can be reopened.'); } $slaDays = $this->slaPolicy->daysForFinding($finding, $tenant); $dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt); return $this->mutateAndAudit( finding: $finding, tenant: $tenant, actor: null, action: AuditActionId::FindingReopened, context: [ 'metadata' => [ 'reopened_at' => $reopenedAt->toIso8601String(), 'sla_days' => $slaDays, 'due_at' => $dueAt->toIso8601String(), 'system_origin' => true, ], ], mutate: function (Finding $record) use ($mutate, $reopenedAt, $slaDays, $dueAt): void { if ($mutate !== null) { $mutate($record); } $record->status = Finding::STATUS_REOPENED; $record->reopened_at = $reopenedAt; $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; }, actorType: AuditActorType::System, operationRunId: $operationRunId, ); } /** * @param array $capabilities */ private function authorize(Finding $finding, Tenant $tenant, User $actor, array $capabilities): void { if (! $actor->canAccessTenant($tenant)) { throw new NotFoundHttpException; } $this->assertFindingOwnedByTenant($finding, $tenant); foreach ($capabilities as $capability) { if ($this->capabilityResolver->can($actor, $tenant, $capability)) { return; } } throw new AuthorizationException('Missing capability for finding workflow action.'); } 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 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|AuditActionId $action, array $context, callable $mutate, ?AuditActorType $actorType = null, ?int $operationRunId = null, ): Finding { $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; $resolvedFinding = DB::transaction(function () use ($finding, $tenant, $actor, $action, $metadata, $mutate, $actorType, $operationRunId): Finding { /** @var Finding $record */ $record = Finding::query() ->whereKey($finding->getKey()) ->lockForUpdate() ->firstOrFail(); $before = $this->auditSnapshot($record); $mutate($record); $record->save(); $after = $this->auditSnapshot($record); $auditMetadata = array_merge($metadata, [ 'finding_id' => (int) $record->getKey(), 'before_status' => $before['status'] ?? null, 'after_status' => $after['status'] ?? null, 'before' => $before, 'after' => $after, '_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType), ]); $this->auditLogger->log( tenant: $tenant, action: $action, actorId: $actor?->getKey() !== null ? (int) $actor->getKey() : null, actorEmail: $actor?->email, actorName: $actor?->name, resourceType: 'finding', resourceId: (string) $record->getKey(), context: ['metadata' => $auditMetadata], actorType: $actorType, operationRunId: $operationRunId, ); return $record; }); return $resolvedFinding->refresh(); } /** * @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, ]; } /** * @param array $before * @param array $after * @param array $metadata */ private function dedupeKey( string|AuditActionId $action, Finding $finding, array $before, array $after, array $metadata, ?User $actor, ?AuditActorType $actorType = null, ): string { $payload = [ 'action' => $action instanceof AuditActionId ? $action->value : $action, 'finding_id' => (int) $finding->getKey(), 'actor_id' => $actor?->getKey() !== null ? (int) $actor->getKey() : null, 'actor_type' => $actorType?->value, 'before' => $before, 'after' => $after, 'assignee_user_id' => $metadata['assignee_user_id'] ?? null, 'owner_user_id' => $metadata['owner_user_id'] ?? null, 'resolved_reason' => $metadata['resolved_reason'] ?? null, 'closed_reason' => $metadata['closed_reason'] ?? null, ]; $encoded = json_encode($payload); return hash('sha256', is_string($encoded) ? $encoded : serialize($payload)); } }