TenantAtlas/app/Services/Findings/FindingWorkflowService.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

539 lines
19 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\Audit\AuditActionId;
use App\Support\Audit\AuditActorType;
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: 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<int, string> $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<string, mixed> $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<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,
];
}
/**
* @param array<string, mixed> $before
* @param array<string, mixed> $after
* @param array<string, mixed> $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));
}
}