From 242881c04e2a604059fea44c8496bcef4ec7bfe0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 13 Jan 2026 23:48:16 +0100 Subject: [PATCH] feat(044): add drift findings foundation --- app/Filament/Pages/DriftLanding.php | 83 +++++++++++++++++++ app/Filament/Resources/FindingResource.php | 65 +++++++++++++++ .../FindingResource/Pages/ListFindings.php | 11 +++ .../FindingResource/Pages/ViewFinding.php | 11 +++ app/Jobs/GenerateDriftFindingsJob.php | 30 +++++++ app/Models/Finding.php | 65 +++++++++++++++ app/Policies/FindingPolicy.php | 66 +++++++++++++++ app/Providers/AppServiceProvider.php | 5 ++ app/Services/Drift/DriftEvidence.php | 31 +++++++ app/Services/Drift/DriftHasher.php | 33 ++++++++ app/Services/Drift/DriftRunSelector.php | 40 +++++++++ app/Services/Drift/DriftScopeKey.php | 13 +++ database/factories/FindingFactory.php | 37 +++++++++ ...026_01_13_223311_create_findings_table.php | 56 +++++++++++++ .../filament/pages/drift-landing.blade.php | 15 ++++ .../Drift/DriftBaselineSelectionTest.php | 60 ++++++++++++++ .../Drift/DriftGenerationDispatchTest.php | 60 ++++++++++++++ 17 files changed, 681 insertions(+) create mode 100644 app/Filament/Pages/DriftLanding.php create mode 100644 app/Filament/Resources/FindingResource.php create mode 100644 app/Filament/Resources/FindingResource/Pages/ListFindings.php create mode 100644 app/Filament/Resources/FindingResource/Pages/ViewFinding.php create mode 100644 app/Jobs/GenerateDriftFindingsJob.php create mode 100644 app/Models/Finding.php create mode 100644 app/Policies/FindingPolicy.php create mode 100644 app/Services/Drift/DriftEvidence.php create mode 100644 app/Services/Drift/DriftHasher.php create mode 100644 app/Services/Drift/DriftRunSelector.php create mode 100644 app/Services/Drift/DriftScopeKey.php create mode 100644 database/factories/FindingFactory.php create mode 100644 database/migrations/2026_01_13_223311_create_findings_table.php create mode 100644 resources/views/filament/pages/drift-landing.blade.php create mode 100644 tests/Feature/Drift/DriftBaselineSelectionTest.php create mode 100644 tests/Feature/Drift/DriftGenerationDispatchTest.php diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php new file mode 100644 index 0000000..cfdcb44 --- /dev/null +++ b/app/Filament/Pages/DriftLanding.php @@ -0,0 +1,83 @@ +user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + $latestSuccessful = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('status', InventorySyncRun::STATUS_SUCCESS) + ->whereNotNull('finished_at') + ->orderByDesc('finished_at') + ->first(); + + if (! $latestSuccessful instanceof InventorySyncRun) { + return; + } + + $scopeKey = (string) $latestSuccessful->selection_hash; + + $selector = app(DriftRunSelector::class); + $comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey); + + if ($comparison === null) { + return; + } + + $baseline = $comparison['baseline']; + $current = $comparison['current']; + + $exists = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('baseline_run_id', $baseline->getKey()) + ->where('current_run_id', $current->getKey()) + ->exists(); + + if ($exists) { + return; + } + + GenerateDriftFindingsJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + baselineRunId: (int) $baseline->getKey(), + currentRunId: (int) $current->getKey(), + scopeKey: $scopeKey, + ); + } + + public function getFindingsUrl(): string + { + return FindingResource::getUrl('index', tenant: Tenant::current()); + } +} diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php new file mode 100644 index 0000000..bfd097e --- /dev/null +++ b/app/Filament/Resources/FindingResource.php @@ -0,0 +1,65 @@ +columns([ + Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'), + Tables\Columns\TextColumn::make('status')->badge(), + Tables\Columns\TextColumn::make('severity')->badge(), + Tables\Columns\TextColumn::make('subject_type')->label('Subject')->searchable(), + Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('created_at')->since()->label('Created'), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListFindings::route('/'), + 'view' => Pages\ViewFinding::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php new file mode 100644 index 0000000..cb872dd --- /dev/null +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -0,0 +1,11 @@ + */ + use HasFactory; + + public const string FINDING_TYPE_DRIFT = 'drift'; + + public const string SEVERITY_LOW = 'low'; + + public const string SEVERITY_MEDIUM = 'medium'; + + public const string SEVERITY_HIGH = 'high'; + + public const string STATUS_NEW = 'new'; + + public const string STATUS_ACKNOWLEDGED = 'acknowledged'; + + protected $guarded = []; + + protected $casts = [ + 'acknowledged_at' => 'datetime', + 'evidence_jsonb' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function baselineRun(): BelongsTo + { + return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id'); + } + + public function currentRun(): BelongsTo + { + return $this->belongsTo(InventorySyncRun::class, 'current_run_id'); + } + + public function acknowledgedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'acknowledged_by_user_id'); + } + + 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(), + ]); + } +} diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php new file mode 100644 index 0000000..6a779e0 --- /dev/null +++ b/app/Policies/FindingPolicy.php @@ -0,0 +1,66 @@ +canAccessTenant($tenant); + } + + public function view(User $user, Finding $finding): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $finding->tenant_id === (int) $tenant->getKey(); + } + + public function update(User $user, Finding $finding): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { + return false; + } + + $role = $user->tenantRole($tenant); + + return match ($role) { + TenantRole::Owner, + TenantRole::Manager, + TenantRole::Operator => true, + default => false, + }; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a605e4a..8e59c1e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,10 +3,14 @@ namespace App\Providers; use App\Models\BackupSchedule; +use App\Models\BulkOperationRun; +use App\Models\Finding; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; use App\Policies\BackupSchedulePolicy; +use App\Policies\BulkOperationRunPolicy; +use App\Policies\FindingPolicy; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; @@ -108,5 +112,6 @@ public function boot(): void Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class); + Gate::policy(Finding::class, FindingPolicy::class); } } diff --git a/app/Services/Drift/DriftEvidence.php b/app/Services/Drift/DriftEvidence.php new file mode 100644 index 0000000..f5c4d96 --- /dev/null +++ b/app/Services/Drift/DriftEvidence.php @@ -0,0 +1,31 @@ + $payload + * @return array + */ + public function sanitize(array $payload): array + { + $allowedKeys = [ + 'change_type', + 'summary', + 'baseline', + 'current', + 'diff', + 'notes', + ]; + + $safe = []; + foreach ($allowedKeys as $key) { + if (array_key_exists($key, $payload)) { + $safe[$key] = $payload[$key]; + } + } + + return $safe; + } +} diff --git a/app/Services/Drift/DriftHasher.php b/app/Services/Drift/DriftHasher.php new file mode 100644 index 0000000..6ca3aee --- /dev/null +++ b/app/Services/Drift/DriftHasher.php @@ -0,0 +1,33 @@ +normalize($scopeKey), + $this->normalize($subjectType), + $this->normalize($subjectExternalId), + $this->normalize($changeType), + $this->normalize($baselineHash), + $this->normalize($currentHash), + ]; + + return hash('sha256', implode('|', $parts)); + } + + private function normalize(string $value): string + { + return trim(mb_strtolower($value)); + } +} diff --git a/app/Services/Drift/DriftRunSelector.php b/app/Services/Drift/DriftRunSelector.php new file mode 100644 index 0000000..a320f5d --- /dev/null +++ b/app/Services/Drift/DriftRunSelector.php @@ -0,0 +1,40 @@ +where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $scopeKey) + ->where('status', InventorySyncRun::STATUS_SUCCESS) + ->whereNotNull('finished_at') + ->orderByDesc('finished_at') + ->limit(2) + ->get(); + + if ($runs->count() < 2) { + return null; + } + + $current = $runs->first(); + $baseline = $runs->last(); + + if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) { + return null; + } + + return [ + 'baseline' => $baseline, + 'current' => $current, + ]; + } +} diff --git a/app/Services/Drift/DriftScopeKey.php b/app/Services/Drift/DriftScopeKey.php new file mode 100644 index 0000000..f6e9405 --- /dev/null +++ b/app/Services/Drift/DriftScopeKey.php @@ -0,0 +1,13 @@ +selection_hash; + } +} diff --git a/database/factories/FindingFactory.php b/database/factories/FindingFactory.php new file mode 100644 index 0000000..408ce4e --- /dev/null +++ b/database/factories/FindingFactory.php @@ -0,0 +1,37 @@ + + */ +class FindingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'scope_key' => hash('sha256', fake()->uuid()), + 'baseline_run_id' => null, + 'current_run_id' => null, + 'fingerprint' => hash('sha256', fake()->uuid()), + 'subject_type' => 'assignment', + 'subject_external_id' => fake()->uuid(), + 'severity' => Finding::SEVERITY_MEDIUM, + 'status' => Finding::STATUS_NEW, + 'acknowledged_at' => null, + 'acknowledged_by_user_id' => null, + 'evidence_jsonb' => [], + ]; + } +} diff --git a/database/migrations/2026_01_13_223311_create_findings_table.php b/database/migrations/2026_01_13_223311_create_findings_table.php new file mode 100644 index 0000000..5307a51 --- /dev/null +++ b/database/migrations/2026_01_13_223311_create_findings_table.php @@ -0,0 +1,56 @@ +id(); + + $table->foreignId('tenant_id')->constrained(); + + $table->string('finding_type'); + $table->string('scope_key'); + + $table->foreignId('baseline_run_id')->nullable()->constrained('inventory_sync_runs'); + $table->foreignId('current_run_id')->nullable()->constrained('inventory_sync_runs'); + + $table->string('fingerprint', 64); + + $table->string('subject_type'); + $table->string('subject_external_id'); + + $table->string('severity'); + $table->string('status'); + + $table->timestampTz('acknowledged_at')->nullable(); + $table->foreignId('acknowledged_by_user_id')->nullable()->constrained('users'); + + $table->jsonb('evidence_jsonb')->nullable(); + + $table->timestamps(); + + $table->unique(['tenant_id', 'fingerprint']); + + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'scope_key']); + $table->index(['tenant_id', 'baseline_run_id']); + $table->index(['tenant_id', 'current_run_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('findings'); + } +}; diff --git a/resources/views/filament/pages/drift-landing.blade.php b/resources/views/filament/pages/drift-landing.blade.php new file mode 100644 index 0000000..9b631d5 --- /dev/null +++ b/resources/views/filament/pages/drift-landing.blade.php @@ -0,0 +1,15 @@ + + +
+
+ Review new drift findings between the last two inventory sync runs for the current scope. +
+ +
+ + Findings + +
+
+
+
diff --git a/tests/Feature/Drift/DriftBaselineSelectionTest.php b/tests/Feature/Drift/DriftBaselineSelectionTest.php new file mode 100644 index 0000000..c0e273b --- /dev/null +++ b/tests/Feature/Drift/DriftBaselineSelectionTest.php @@ -0,0 +1,60 @@ +actingAs($user); + + $scopeKey = hash('sha256', 'scope-a'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(3), + ]); + + $baseline = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + $current = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_FAILED, + 'finished_at' => now(), + ]); + + $selector = app(DriftRunSelector::class); + + $selected = $selector->selectBaselineAndCurrent($tenant, $scopeKey); + + expect($selected)->not->toBeNull(); + expect($selected['baseline']->getKey())->toBe($baseline->getKey()); + expect($selected['current']->getKey())->toBe($current->getKey()); +}); + +test('it returns null when fewer than two successful runs exist for scope', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + + $scopeKey = hash('sha256', 'scope-b'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + $selector = app(DriftRunSelector::class); + + expect($selector->selectBaselineAndCurrent($tenant, $scopeKey))->toBeNull(); +}); diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php new file mode 100644 index 0000000..3e54f9a --- /dev/null +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -0,0 +1,60 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $scopeKey = hash('sha256', 'scope-dispatch'); + + $baseline = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + $current = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + Livewire::test(DriftLanding::class); + + Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->userId === (int) $user->getKey() + && $job->baselineRunId === (int) $baseline->getKey() + && $job->currentRunId === (int) $current->getKey() + && $job->scopeKey === $scopeKey; + }); +}); + +test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $scopeKey = hash('sha256', 'scope-blocked'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + Livewire::test(DriftLanding::class); + + Queue::assertNothingPushed(); +});