TenantAtlas/app/Services/Findings/FindingWorkflowService.php
ahmido 7ac53f4cc4 feat(111): findings workflow + SLA settings (#135)
Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation.

Key changes:
- Findings workflow service + SLA policy and alerting.
- Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults.
- New migrations, jobs, command, UI/resource updates, and comprehensive test coverage.

Tests:
- `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #135
2026-02-25 01:48:01 +00:00

381 lines
13 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Findings;
use App\Models\Finding;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Intune\AuditLogger;
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 FindingWorkflowService
{
public function __construct(
private readonly FindingSlaPolicy $slaPolicy,
private readonly AuditLogger $auditLogger,
private readonly CapabilityResolver $capabilityResolver,
) {}
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
{
$this->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<int, string> $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<string, mixed> $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<string, mixed>
*/
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,
];
}
}