Beschreibung Implementiert das Drift MVP Feature (Spec: 044-drift-mvp) mit Fokus auf automatische Drift-Erkennung zwischen Inventory Sync Runs und Bulk-Triage für Findings. Was wurde implementiert? Drift-Erkennung: Vergleicht Policy-Snapshots, Assignments und Scope Tags zwischen Baseline- und Current-Runs. Deterministische Fingerprints verhindern Duplikate. Findings UI: Neue Filament Resource für Findings mit Listen- und Detail-Ansicht. DB-only Diffs (keine Graph-Calls zur Laufzeit). Bulk Acknowledge: "Acknowledge selected" (Bulk-Action auf der Liste) "Acknowledge all matching" (Header-Action, respektiert aktuelle Filter; Type-to-Confirm bei >100 Findings) Scope Tag Fix: Behebt False Positives bei Legacy-Daten ohne scope_tags.ids (inferiert Default-Werte). Authorization: Tenant-isoliert, Rollen-basiert (Owner/Manager/Operator können acknowledge). Tests: Vollständige Pest-Coverage (28 Tests, 347 Assertions) für Drift-Logik, UI und Bulk-Actions. Warum diese Änderungen? Problem: Keine automatisierte Drift-Erkennung; manuelle Triage bei vielen Findings ist mühsam. Lösung: Async Drift-Generierung mit persistenter Findings-Tabelle. Safe Bulk-Tools für Massen-Triage ohne Deletes. Konformität: Folgt AGENTS.md Workflow, Spec-Kit (Tasks + Checklists abgehakt), Laravel/Filament Best Practices. Technische Details Neue Dateien: ~40 (Models, Services, Tests, Views, Migrations) Änderungen: Filament Resources, Jobs, Policies DB: Neue findings Tabelle (JSONB für Evidence, Indexes für Performance) Tests: ./vendor/bin/sail artisan test tests/Feature/Drift --parallel → 28 passed Migration: ./vendor/bin/sail artisan migrate (neue Tabelle + Indexes) Screenshots / Links Spec: spec.md Tasks: tasks.md (alle abgehakt) UI: Findings-Liste mit Bulk-Actions; Detail-View mit Diffs Checklist Tests passieren (parallel + serial) Code formatiert (./vendor/bin/pint --dirty) Migration reversibel Tenant-Isolation enforced No Graph-Calls in Views Authorization checks Spec + Tasks aligned Deployment Notes Neue Migration: create_findings_table Neue Permissions: drift.view, drift.acknowledge Queue-Job: GenerateDriftFindingsJob (async, deduped)
172 lines
5.7 KiB
PHP
172 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\FindingResource\Pages;
|
|
|
|
use App\Filament\Resources\FindingResource;
|
|
use App\Models\Finding;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Filament\Actions;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\ListRecords;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Gate;
|
|
|
|
class ListFindings extends ListRecords
|
|
{
|
|
protected static string $resource = FindingResource::class;
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [
|
|
Actions\Action::make('acknowledge_all_matching')
|
|
->label('Acknowledge all matching')
|
|
->icon('heroicon-o-check')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->authorize(function (): bool {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant || ! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
|
|
|
return $user->can('update', $probe);
|
|
})
|
|
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
|
->modalDescription(function (): string {
|
|
$count = $this->getAllMatchingCount();
|
|
|
|
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
|
})
|
|
->form(function (): array {
|
|
$count = $this->getAllMatchingCount();
|
|
|
|
if ($count <= 100) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
TextInput::make('confirmation')
|
|
->label('Type ACKNOWLEDGE to confirm')
|
|
->required()
|
|
->in(['ACKNOWLEDGE'])
|
|
->validationMessages([
|
|
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
|
]),
|
|
];
|
|
})
|
|
->action(function (array $data): void {
|
|
$tenant = Tenant::current();
|
|
$user = auth()->user();
|
|
|
|
if (! $tenant || ! $user instanceof User) {
|
|
return;
|
|
}
|
|
|
|
$query = $this->buildAllMatchingQuery();
|
|
$count = (clone $query)->count();
|
|
|
|
if ($count === 0) {
|
|
Notification::make()
|
|
->title('No matching findings')
|
|
->body('There are no new findings matching the current filters.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$firstRecord = (clone $query)->first();
|
|
if ($firstRecord instanceof Finding) {
|
|
Gate::authorize('update', $firstRecord);
|
|
}
|
|
|
|
$updated = $query->update([
|
|
'status' => Finding::STATUS_ACKNOWLEDGED,
|
|
'acknowledged_at' => now(),
|
|
'acknowledged_by_user_id' => $user->getKey(),
|
|
]);
|
|
|
|
$this->deselectAllTableRecords();
|
|
$this->resetPage();
|
|
|
|
Notification::make()
|
|
->title('Bulk acknowledge completed')
|
|
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
|
->success()
|
|
->send();
|
|
}),
|
|
];
|
|
}
|
|
|
|
protected function buildAllMatchingQuery(): Builder
|
|
{
|
|
$tenant = Tenant::current();
|
|
|
|
$query = Finding::query();
|
|
|
|
if (! $tenant) {
|
|
return $query->whereRaw('1 = 0');
|
|
}
|
|
|
|
$query->where('tenant_id', $tenant->getKey());
|
|
|
|
$query->where('status', Finding::STATUS_NEW);
|
|
|
|
$findingType = $this->getFindingTypeFilterValue();
|
|
if (is_string($findingType) && $findingType !== '') {
|
|
$query->where('finding_type', $findingType);
|
|
}
|
|
|
|
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
|
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
|
if (is_string($scopeKey) && $scopeKey !== '') {
|
|
$query->where('scope_key', $scopeKey);
|
|
}
|
|
|
|
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
|
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
|
if (is_numeric($baselineRunId)) {
|
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
|
}
|
|
|
|
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
|
if (is_numeric($currentRunId)) {
|
|
$query->where('current_run_id', (int) $currentRunId);
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
protected function getAllMatchingCount(): int
|
|
{
|
|
return (int) $this->buildAllMatchingQuery()->count();
|
|
}
|
|
|
|
protected function getStatusFilterValue(): string
|
|
{
|
|
$state = $this->getTableFilterState('status') ?? [];
|
|
$value = Arr::get($state, 'value');
|
|
|
|
return is_string($value) && $value !== ''
|
|
? $value
|
|
: Finding::STATUS_NEW;
|
|
}
|
|
|
|
protected function getFindingTypeFilterValue(): string
|
|
{
|
|
$state = $this->getTableFilterState('finding_type') ?? [];
|
|
$value = Arr::get($state, 'value');
|
|
|
|
return is_string($value) && $value !== ''
|
|
? $value
|
|
: Finding::FINDING_TYPE_DRIFT;
|
|
}
|
|
}
|