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
199 lines
4.9 KiB
PHP
199 lines
4.9 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
|
|
class Finding extends Model
|
|
{
|
|
/** @use HasFactory<\Database\Factories\FindingFactory> */
|
|
use DerivesWorkspaceIdFromTenant;
|
|
|
|
use HasFactory;
|
|
|
|
public const string FINDING_TYPE_DRIFT = 'drift';
|
|
|
|
public const string FINDING_TYPE_PERMISSION_POSTURE = 'permission_posture';
|
|
|
|
public const string FINDING_TYPE_ENTRA_ADMIN_ROLES = 'entra_admin_roles';
|
|
|
|
public const string SEVERITY_LOW = 'low';
|
|
|
|
public const string SEVERITY_MEDIUM = 'medium';
|
|
|
|
public const string SEVERITY_HIGH = 'high';
|
|
|
|
public const string SEVERITY_CRITICAL = 'critical';
|
|
|
|
public const string STATUS_NEW = 'new';
|
|
|
|
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
|
|
|
public const string STATUS_TRIAGED = 'triaged';
|
|
|
|
public const string STATUS_IN_PROGRESS = 'in_progress';
|
|
|
|
public const string STATUS_REOPENED = 'reopened';
|
|
|
|
public const string STATUS_RESOLVED = 'resolved';
|
|
|
|
public const string STATUS_CLOSED = 'closed';
|
|
|
|
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
|
|
|
|
protected $guarded = [];
|
|
|
|
protected $casts = [
|
|
'acknowledged_at' => 'datetime',
|
|
'closed_at' => 'datetime',
|
|
'due_at' => 'datetime',
|
|
'evidence_jsonb' => 'array',
|
|
'first_seen_at' => 'datetime',
|
|
'in_progress_at' => 'datetime',
|
|
'last_seen_at' => 'datetime',
|
|
'reopened_at' => 'datetime',
|
|
'resolved_at' => 'datetime',
|
|
'sla_days' => 'integer',
|
|
'times_seen' => 'integer',
|
|
'triaged_at' => 'datetime',
|
|
];
|
|
|
|
public function tenant(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Tenant::class);
|
|
}
|
|
|
|
public function baselineRun(): BelongsTo
|
|
{
|
|
return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id');
|
|
}
|
|
|
|
public function currentRun(): BelongsTo
|
|
{
|
|
return $this->belongsTo(OperationRun::class, 'current_operation_run_id');
|
|
}
|
|
|
|
public function acknowledgedByUser(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
|
}
|
|
|
|
public function ownerUser(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'owner_user_id');
|
|
}
|
|
|
|
public function assigneeUser(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'assignee_user_id');
|
|
}
|
|
|
|
public function closedByUser(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class, 'closed_by_user_id');
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public static function openStatuses(): array
|
|
{
|
|
return [
|
|
self::STATUS_NEW,
|
|
self::STATUS_TRIAGED,
|
|
self::STATUS_IN_PROGRESS,
|
|
self::STATUS_REOPENED,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public static function terminalStatuses(): array
|
|
{
|
|
return [
|
|
self::STATUS_RESOLVED,
|
|
self::STATUS_CLOSED,
|
|
self::STATUS_RISK_ACCEPTED,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
public static function openStatusesForQuery(): array
|
|
{
|
|
return [
|
|
...self::openStatuses(),
|
|
self::STATUS_ACKNOWLEDGED,
|
|
];
|
|
}
|
|
|
|
public static function canonicalizeStatus(?string $status): ?string
|
|
{
|
|
if ($status === self::STATUS_ACKNOWLEDGED) {
|
|
return self::STATUS_TRIAGED;
|
|
}
|
|
|
|
return $status;
|
|
}
|
|
|
|
public static function isOpenStatus(?string $status): bool
|
|
{
|
|
return is_string($status) && in_array($status, self::openStatusesForQuery(), true);
|
|
}
|
|
|
|
public static function isTerminalStatus(?string $status): bool
|
|
{
|
|
$canonical = self::canonicalizeStatus($status);
|
|
|
|
return is_string($canonical) && in_array($canonical, self::terminalStatuses(), true);
|
|
}
|
|
|
|
public function hasOpenStatus(): bool
|
|
{
|
|
return self::isOpenStatus($this->status);
|
|
}
|
|
|
|
public function acknowledge(User $user): void
|
|
{
|
|
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
|
return;
|
|
}
|
|
|
|
$this->forceFill([
|
|
'status' => self::STATUS_ACKNOWLEDGED,
|
|
'acknowledged_at' => now(),
|
|
'acknowledged_by_user_id' => $user->getKey(),
|
|
]);
|
|
|
|
$this->save();
|
|
}
|
|
|
|
/**
|
|
* Auto-resolve the finding.
|
|
*/
|
|
public function resolve(string $reason): void
|
|
{
|
|
$this->status = self::STATUS_RESOLVED;
|
|
$this->resolved_at = now();
|
|
$this->resolved_reason = $reason;
|
|
$this->save();
|
|
}
|
|
|
|
/**
|
|
* Re-open a resolved finding.
|
|
*/
|
|
public function reopen(array $evidence): void
|
|
{
|
|
$this->status = self::STATUS_NEW;
|
|
$this->resolved_at = null;
|
|
$this->resolved_reason = null;
|
|
$this->evidence_jsonb = $evidence;
|
|
$this->save();
|
|
}
|
|
}
|