Implements Spec 115 (Baseline Operability & Alert Integration). Key changes - Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares) - Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics - Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle - Baseline Compare UX: shared stats layer + landing/widget consistency Notes - Livewire v4 / Filament v5 compatible. - Destructive-like actions require confirmation (no new destructive actions added here). Tests - `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #140
272 lines
10 KiB
PHP
272 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Models\BaselineProfile;
|
|
use App\Models\BaselineTenantAssignment;
|
|
use App\Models\Finding;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Tenant;
|
|
|
|
final class BaselineCompareStats
|
|
{
|
|
/**
|
|
* @param array<string, int> $severityCounts
|
|
*/
|
|
private function __construct(
|
|
public readonly string $state,
|
|
public readonly ?string $message,
|
|
public readonly ?string $profileName,
|
|
public readonly ?int $profileId,
|
|
public readonly ?int $snapshotId,
|
|
public readonly ?int $operationRunId,
|
|
public readonly ?int $findingsCount,
|
|
public readonly array $severityCounts,
|
|
public readonly ?string $lastComparedHuman,
|
|
public readonly ?string $lastComparedIso,
|
|
public readonly ?string $failureReason,
|
|
) {}
|
|
|
|
public static function forTenant(?Tenant $tenant): self
|
|
{
|
|
if (! $tenant instanceof Tenant) {
|
|
return self::empty('no_tenant', 'No tenant selected.');
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->first();
|
|
|
|
if (! $assignment instanceof BaselineTenantAssignment) {
|
|
return self::empty(
|
|
'no_assignment',
|
|
'This tenant has no baseline assignment. A workspace manager can assign a baseline profile to this tenant.',
|
|
);
|
|
}
|
|
|
|
$profile = $assignment->baselineProfile;
|
|
|
|
if (! $profile instanceof BaselineProfile) {
|
|
return self::empty(
|
|
'no_assignment',
|
|
'The assigned baseline profile no longer exists.',
|
|
);
|
|
}
|
|
|
|
$profileName = (string) $profile->name;
|
|
$profileId = (int) $profile->getKey();
|
|
$snapshotId = $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null;
|
|
|
|
if ($snapshotId === null) {
|
|
return self::empty(
|
|
'no_snapshot',
|
|
'The baseline profile has no active snapshot yet. A workspace manager needs to capture a snapshot first.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
);
|
|
}
|
|
|
|
$latestRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'baseline_compare')
|
|
->latest('id')
|
|
->first();
|
|
|
|
// Active run (queued/running)
|
|
if ($latestRun instanceof OperationRun && in_array($latestRun->status, ['queued', 'running'], true)) {
|
|
return new self(
|
|
state: 'comparing',
|
|
message: 'A baseline comparison is currently in progress.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
operationRunId: (int) $latestRun->getKey(),
|
|
findingsCount: null,
|
|
severityCounts: [],
|
|
lastComparedHuman: null,
|
|
lastComparedIso: null,
|
|
failureReason: null,
|
|
);
|
|
}
|
|
|
|
// Failed run — explicit error state
|
|
if ($latestRun instanceof OperationRun && $latestRun->outcome === 'failed') {
|
|
$failureSummary = is_array($latestRun->failure_summary) ? $latestRun->failure_summary : [];
|
|
$failureReason = $failureSummary['message']
|
|
?? $failureSummary['reason']
|
|
?? 'The comparison job failed. Check the run details for more information.';
|
|
|
|
return new self(
|
|
state: 'failed',
|
|
message: (string) $failureReason,
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
operationRunId: (int) $latestRun->getKey(),
|
|
findingsCount: null,
|
|
severityCounts: [],
|
|
lastComparedHuman: $latestRun->finished_at?->diffForHumans(),
|
|
lastComparedIso: $latestRun->finished_at?->toIso8601String(),
|
|
failureReason: (string) $failureReason,
|
|
);
|
|
}
|
|
|
|
$lastComparedHuman = null;
|
|
$lastComparedIso = null;
|
|
|
|
if ($latestRun instanceof OperationRun && $latestRun->finished_at !== null) {
|
|
$lastComparedHuman = $latestRun->finished_at->diffForHumans();
|
|
$lastComparedIso = $latestRun->finished_at->toIso8601String();
|
|
}
|
|
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
// Single grouped query instead of 4 separate COUNT queries
|
|
$severityRows = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('source', 'baseline.compare')
|
|
->where('scope_key', $scopeKey)
|
|
->whereIn('status', Finding::openStatusesForQuery())
|
|
->selectRaw('severity, count(*) as cnt')
|
|
->groupBy('severity')
|
|
->pluck('cnt', 'severity');
|
|
|
|
$totalFindings = (int) $severityRows->sum();
|
|
$severityCounts = [
|
|
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
|
|
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
|
|
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
|
|
];
|
|
|
|
if ($totalFindings > 0) {
|
|
return new self(
|
|
state: 'ready',
|
|
message: null,
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
|
findingsCount: $totalFindings,
|
|
severityCounts: $severityCounts,
|
|
lastComparedHuman: $lastComparedHuman,
|
|
lastComparedIso: $lastComparedIso,
|
|
failureReason: null,
|
|
);
|
|
}
|
|
|
|
if ($latestRun instanceof OperationRun && $latestRun->status === 'completed' && $latestRun->outcome === 'succeeded') {
|
|
return new self(
|
|
state: 'ready',
|
|
message: 'No open drift findings for this baseline comparison. The tenant matches the baseline.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
operationRunId: (int) $latestRun->getKey(),
|
|
findingsCount: 0,
|
|
severityCounts: $severityCounts,
|
|
lastComparedHuman: $lastComparedHuman,
|
|
lastComparedIso: $lastComparedIso,
|
|
failureReason: null,
|
|
);
|
|
}
|
|
|
|
return new self(
|
|
state: 'idle',
|
|
message: 'Baseline profile is assigned and has a snapshot. Run "Compare Now" to check for drift.',
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: $snapshotId,
|
|
operationRunId: null,
|
|
findingsCount: null,
|
|
severityCounts: $severityCounts,
|
|
lastComparedHuman: $lastComparedHuman,
|
|
lastComparedIso: $lastComparedIso,
|
|
failureReason: null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a DTO for widget consumption (only open/new findings).
|
|
*/
|
|
public static function forWidget(?Tenant $tenant): self
|
|
{
|
|
if (! $tenant instanceof Tenant) {
|
|
return self::empty('no_tenant', null);
|
|
}
|
|
|
|
$assignment = BaselineTenantAssignment::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->with('baselineProfile')
|
|
->first();
|
|
|
|
if (! $assignment instanceof BaselineTenantAssignment || $assignment->baselineProfile === null) {
|
|
return self::empty('no_assignment', null);
|
|
}
|
|
|
|
$profile = $assignment->baselineProfile;
|
|
$scopeKey = 'baseline_profile:'.$profile->getKey();
|
|
|
|
$severityRows = Finding::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
|
->where('source', 'baseline.compare')
|
|
->where('scope_key', $scopeKey)
|
|
->where('status', Finding::STATUS_NEW)
|
|
->selectRaw('severity, count(*) as cnt')
|
|
->groupBy('severity')
|
|
->pluck('cnt', 'severity');
|
|
|
|
$totalFindings = (int) $severityRows->sum();
|
|
|
|
$latestRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('type', 'baseline_compare')
|
|
->where('context->baseline_profile_id', (string) $profile->getKey())
|
|
->whereNotNull('completed_at')
|
|
->latest('completed_at')
|
|
->first();
|
|
|
|
return new self(
|
|
state: $totalFindings > 0 ? 'ready' : 'idle',
|
|
message: null,
|
|
profileName: (string) $profile->name,
|
|
profileId: (int) $profile->getKey(),
|
|
snapshotId: $profile->active_snapshot_id !== null ? (int) $profile->active_snapshot_id : null,
|
|
operationRunId: $latestRun instanceof OperationRun ? (int) $latestRun->getKey() : null,
|
|
findingsCount: $totalFindings,
|
|
severityCounts: [
|
|
'high' => (int) ($severityRows[Finding::SEVERITY_HIGH] ?? 0),
|
|
'medium' => (int) ($severityRows[Finding::SEVERITY_MEDIUM] ?? 0),
|
|
'low' => (int) ($severityRows[Finding::SEVERITY_LOW] ?? 0),
|
|
],
|
|
lastComparedHuman: $latestRun?->finished_at?->diffForHumans(),
|
|
lastComparedIso: $latestRun?->finished_at?->toIso8601String(),
|
|
failureReason: null,
|
|
);
|
|
}
|
|
|
|
private static function empty(
|
|
string $state,
|
|
?string $message,
|
|
?string $profileName = null,
|
|
?int $profileId = null,
|
|
): self {
|
|
return new self(
|
|
state: $state,
|
|
message: $message,
|
|
profileName: $profileName,
|
|
profileId: $profileId,
|
|
snapshotId: null,
|
|
operationRunId: null,
|
|
findingsCount: null,
|
|
severityCounts: [],
|
|
lastComparedHuman: null,
|
|
lastComparedIso: null,
|
|
failureReason: null,
|
|
);
|
|
}
|
|
}
|