TenantAtlas/app/Services/Drift/DriftFindingGenerator.php
ahmido c57f680f39 feat: Workspace settings slices v1 (backup, drift, operations) (#120)
Implements Spec 098: workspace-level settings slices for Backup retention, Drift severity mapping, and Operations retention/threshold.

Spec
- specs/098-settings-slices-v1-backup-drift-ops/spec.md

What changed
- Workspace Settings page: grouped Backup/Drift/Operations sections, unset-input UX w/ helper text, per-setting reset actions (confirmed)
- Settings registry: adds/updates validation + normalization (incl. drift severity mapping normalization to lowercase)
- Backup retention: adds workspace default + floor clamp; job clamps effective keep-last up to floor
- Drift findings: optional workspace severity mapping; adds `critical` severity support + badge mapping
- Operations pruning: retention computed per workspace via settings; scheduler unchanged; stuck threshold is storage-only

Safety / Compliance notes
- Filament v5 / Livewire v4: no Livewire v3 usage; relies on existing Filament v5 + Livewire v4 stack
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php)
- Destructive actions: per-setting reset uses Filament actions with confirmation
- Global search: not affected (no resource changes)
- Assets: no new assets registered; no `filament:assets` changes

Tests
- vendor/bin/sail artisan test --compact tests/Feature/SettingsFoundation/WorkspaceSettingsManageTest.php \
  tests/Feature/SettingsFoundation/WorkspaceSettingsViewOnlyTest.php \
  tests/Feature/BackupScheduling/BackupScheduleLifecycleTest.php \
  tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php \
  tests/Feature/Scheduling/PruneOldOperationRunsScheduleTest.php \
  tests/Unit/Badges/FindingBadgesTest.php

Formatting
- vendor/bin/sail bin pint --dirty

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #120
2026-02-16 03:18:33 +00:00

367 lines
16 KiB
PHP

<?php
namespace App\Services\Drift;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
use App\Services\Drift\Normalizers\SettingsNormalizer;
use App\Services\Settings\SettingsResolver;
use Illuminate\Support\Arr;
use RuntimeException;
class DriftFindingGenerator
{
public function __construct(
private readonly DriftHasher $hasher,
private readonly DriftEvidence $evidence,
private readonly SettingsNormalizer $settingsNormalizer,
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
private readonly SettingsResolver $settingsResolver,
) {}
public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
{
if (! $baseline->completed_at || ! $current->completed_at) {
throw new RuntimeException('Baseline/current run must be finished.');
}
/** @var array<string, mixed> $selection */
$selection = is_array($current->context) ? $current->context : [];
$policyTypes = Arr::get($selection, 'policy_types');
if (! is_array($policyTypes)) {
$policyTypes = [];
}
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
$created = 0;
$resolvedSeverity = $this->resolveSeverityForFindingType($tenant, Finding::FINDING_TYPE_DRIFT);
Policy::query()
->where('tenant_id', $tenant->getKey())
->whereIn('policy_type', $policyTypes)
->orderBy('id')
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, $resolvedSeverity, &$created): void {
foreach ($policies as $policy) {
if (! $policy instanceof Policy) {
continue;
}
$baselineVersion = $this->versionForRun($policy, $baseline);
$currentVersion = $this->versionForRun($policy, $current);
if ($baselineVersion instanceof PolicyVersion || $currentVersion instanceof PolicyVersion) {
$policyType = (string) ($policy->policy_type ?? '');
$platform = is_string($policy->platform ?? null) ? $policy->platform : null;
$baselineSnapshot = $baselineVersion instanceof PolicyVersion && is_array($baselineVersion->snapshot)
? $baselineVersion->snapshot
: [];
$currentSnapshot = $currentVersion instanceof PolicyVersion && is_array($currentVersion->snapshot)
? $currentVersion->snapshot
: [];
$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform);
$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform);
$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized);
$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized);
if ($baselineSnapshotHash !== $currentSnapshotHash) {
$changeType = match (true) {
$baselineVersion instanceof PolicyVersion && ! $currentVersion instanceof PolicyVersion => 'removed',
! $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion => 'added',
default => 'modified',
};
$fingerprint = $this->hasher->fingerprint(
tenantId: (int) $tenant->getKey(),
scopeKey: $scopeKey,
subjectType: 'policy',
subjectExternalId: (string) $policy->external_id,
changeType: $changeType,
baselineHash: $baselineSnapshotHash,
currentHash: $currentSnapshotHash,
);
$rawEvidence = [
'change_type' => $changeType,
'summary' => [
'kind' => 'policy_snapshot',
'changed_fields' => ['snapshot_hash'],
],
'baseline' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $baselineVersion?->getKey(),
'snapshot_hash' => $baselineSnapshotHash,
],
'current' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $currentVersion?->getKey(),
'snapshot_hash' => $currentSnapshotHash,
],
];
$finding = Finding::query()->firstOrNew([
'tenant_id' => $tenant->getKey(),
'fingerprint' => $fingerprint,
]);
$wasNew = ! $finding->exists;
$finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'policy',
'subject_external_id' => (string) $policy->external_id,
'severity' => $resolvedSeverity,
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
]);
if ($wasNew) {
$finding->forceFill([
'status' => Finding::STATUS_NEW,
'acknowledged_at' => null,
'acknowledged_by_user_id' => null,
]);
}
$finding->save();
if ($wasNew) {
$created++;
}
}
}
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
continue;
}
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
$baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments);
$currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments);
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
$fingerprint = $this->hasher->fingerprint(
tenantId: (int) $tenant->getKey(),
scopeKey: $scopeKey,
subjectType: 'assignment',
subjectExternalId: (string) $policy->external_id,
changeType: 'modified',
baselineHash: (string) ($baselineAssignmentsHash ?? ''),
currentHash: (string) ($currentAssignmentsHash ?? ''),
);
$rawEvidence = [
'change_type' => 'modified',
'summary' => [
'kind' => 'policy_assignments',
'changed_fields' => ['assignments_hash'],
],
'baseline' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $baselineVersion->getKey(),
'assignments_hash' => $baselineAssignmentsHash,
],
'current' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $currentVersion->getKey(),
'assignments_hash' => $currentAssignmentsHash,
],
];
$finding = Finding::query()->firstOrNew([
'tenant_id' => $tenant->getKey(),
'fingerprint' => $fingerprint,
]);
$wasNew = ! $finding->exists;
$finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'assignment',
'subject_external_id' => (string) $policy->external_id,
'severity' => $resolvedSeverity,
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
]);
if ($wasNew) {
$finding->forceFill([
'status' => Finding::STATUS_NEW,
'acknowledged_at' => null,
'acknowledged_by_user_id' => null,
]);
}
$finding->save();
if ($wasNew) {
$created++;
}
}
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
continue;
}
$baselineScopeTagsHash = $this->hasher->hashNormalized($baselineScopeTagIds);
$currentScopeTagsHash = $this->hasher->hashNormalized($currentScopeTagIds);
if ($baselineScopeTagsHash === $currentScopeTagsHash) {
continue;
}
$fingerprint = $this->hasher->fingerprint(
tenantId: (int) $tenant->getKey(),
scopeKey: $scopeKey,
subjectType: 'scope_tag',
subjectExternalId: (string) $policy->external_id,
changeType: 'modified',
baselineHash: $baselineScopeTagsHash,
currentHash: $currentScopeTagsHash,
);
$rawEvidence = [
'change_type' => 'modified',
'summary' => [
'kind' => 'policy_scope_tags',
'changed_fields' => ['scope_tags_hash'],
],
'baseline' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $baselineVersion->getKey(),
'scope_tags_hash' => $baselineScopeTagsHash,
],
'current' => [
'policy_id' => $policy->external_id,
'policy_version_id' => $currentVersion->getKey(),
'scope_tags_hash' => $currentScopeTagsHash,
],
];
$finding = Finding::query()->firstOrNew([
'tenant_id' => $tenant->getKey(),
'fingerprint' => $fingerprint,
]);
$wasNew = ! $finding->exists;
$finding->forceFill([
'finding_type' => Finding::FINDING_TYPE_DRIFT,
'scope_key' => $scopeKey,
'baseline_operation_run_id' => $baseline->getKey(),
'current_operation_run_id' => $current->getKey(),
'subject_type' => 'scope_tag',
'subject_external_id' => (string) $policy->external_id,
'severity' => $resolvedSeverity,
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
]);
if ($wasNew) {
$finding->forceFill([
'status' => Finding::STATUS_NEW,
'acknowledged_at' => null,
'acknowledged_by_user_id' => null,
]);
}
$finding->save();
if ($wasNew) {
$created++;
}
}
});
return $created;
}
private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion
{
if (! $run->completed_at) {
return null;
}
return PolicyVersion::query()
->where('tenant_id', $policy->tenant_id)
->where('policy_id', $policy->getKey())
->where('captured_at', '<=', $run->completed_at)
->latest('captured_at')
->first();
}
private function resolveSeverityForFindingType(Tenant $tenant, string $findingType): string
{
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace && is_numeric($tenant->workspace_id)) {
$workspace = Workspace::query()->whereKey((int) $tenant->workspace_id)->first();
}
if (! $workspace instanceof Workspace) {
return Finding::SEVERITY_MEDIUM;
}
$resolved = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: 'drift',
key: 'severity_mapping',
tenant: $tenant,
);
if (! is_array($resolved)) {
return Finding::SEVERITY_MEDIUM;
}
foreach ($resolved as $mappedFindingType => $mappedSeverity) {
if (! is_string($mappedFindingType) || ! is_string($mappedSeverity)) {
continue;
}
if ($mappedFindingType !== $findingType) {
continue;
}
$normalizedSeverity = strtolower($mappedSeverity);
if (in_array($normalizedSeverity, $this->supportedSeverities(), true)) {
return $normalizedSeverity;
}
break;
}
return Finding::SEVERITY_MEDIUM;
}
/**
* @return array<int, string>
*/
private function supportedSeverities(): array
{
return [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
];
}
}