381 lines
13 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|