feat/044-drift-mvp (#58)
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)
This commit is contained in:
parent
bc846d7c5c
commit
a449ecec5b
235
app/Filament/Pages/DriftLanding.php
Normal file
235
app/Filament/Pages/DriftLanding.php
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\BulkOperationRunResource;
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Drift\DriftRunSelector;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class DriftLanding extends Page
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Drift';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.drift-landing';
|
||||||
|
|
||||||
|
public ?string $state = null;
|
||||||
|
|
||||||
|
public ?string $message = null;
|
||||||
|
|
||||||
|
public ?string $scopeKey = null;
|
||||||
|
|
||||||
|
public ?int $baselineRunId = null;
|
||||||
|
|
||||||
|
public ?int $currentRunId = null;
|
||||||
|
|
||||||
|
public ?string $baselineFinishedAt = null;
|
||||||
|
|
||||||
|
public ?string $currentFinishedAt = null;
|
||||||
|
|
||||||
|
public ?int $bulkOperationRunId = null;
|
||||||
|
|
||||||
|
/** @var array<string, int>|null */
|
||||||
|
public ?array $statusCounts = null;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return FindingResource::canAccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
$user = auth()->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) {
|
||||||
|
$this->state = 'blocked';
|
||||||
|
$this->message = 'No successful inventory runs found yet.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||||
|
$this->scopeKey = $scopeKey;
|
||||||
|
|
||||||
|
$selector = app(DriftRunSelector::class);
|
||||||
|
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||||
|
|
||||||
|
if ($comparison === null) {
|
||||||
|
$this->state = 'blocked';
|
||||||
|
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseline = $comparison['baseline'];
|
||||||
|
$current = $comparison['current'];
|
||||||
|
|
||||||
|
$this->baselineRunId = (int) $baseline->getKey();
|
||||||
|
$this->currentRunId = (int) $current->getKey();
|
||||||
|
|
||||||
|
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
||||||
|
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$newCount = (int) 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())
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestRun = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
if ($activeRun instanceof BulkOperationRun) {
|
||||||
|
$this->state = 'generating';
|
||||||
|
$this->bulkOperationRunId = (int) $activeRun->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') {
|
||||||
|
$this->state = 'ready';
|
||||||
|
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||||
|
|
||||||
|
$newCount = (int) 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())
|
||||||
|
->where('status', Finding::STATUS_NEW)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||||
|
|
||||||
|
if ($newCount === 0) {
|
||||||
|
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($latestRun instanceof BulkOperationRun && in_array($latestRun->status, ['failed', 'aborted'], true)) {
|
||||||
|
$this->state = 'error';
|
||||||
|
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||||
|
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkOperationService = app(BulkOperationService::class);
|
||||||
|
$run = $bulkOperationService->createRun(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $user,
|
||||||
|
resource: 'drift',
|
||||||
|
action: 'generate',
|
||||||
|
itemIds: [$scopeKey],
|
||||||
|
totalItems: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->update(['idempotency_key' => $idempotencyKey]);
|
||||||
|
|
||||||
|
$this->state = 'generating';
|
||||||
|
$this->bulkOperationRunId = (int) $run->getKey();
|
||||||
|
|
||||||
|
GenerateDriftFindingsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
baselineRunId: (int) $baseline->getKey(),
|
||||||
|
currentRunId: (int) $current->getKey(),
|
||||||
|
scopeKey: $scopeKey,
|
||||||
|
bulkOperationRunId: (int) $run->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFindingsUrl(): string
|
||||||
|
{
|
||||||
|
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBaselineRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->baselineRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCurrentRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->currentRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBulkRunUrl(): ?string
|
||||||
|
{
|
||||||
|
if (! is_int($this->bulkOperationRunId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return BulkOperationRunResource::getUrl('view', ['record' => $this->bulkOperationRunId], tenant: Tenant::current());
|
||||||
|
}
|
||||||
|
}
|
||||||
390
app/Filament/Resources/FindingResource.php
Normal file
390
app/Filament/Resources/FindingResource.php
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Actions\BulkAction;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Infolists\Components\ViewEntry;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class FindingResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Finding::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Findings';
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
Section::make('Finding')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||||
|
TextEntry::make('status')->badge(),
|
||||||
|
TextEntry::make('severity')->badge(),
|
||||||
|
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
||||||
|
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
||||||
|
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||||
|
TextEntry::make('subject_type')->label('Subject type'),
|
||||||
|
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||||
|
TextEntry::make('baseline_run_id')
|
||||||
|
->label('Baseline run')
|
||||||
|
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
||||||
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('current_run_id')
|
||||||
|
->label('Current run')
|
||||||
|
->url(fn (Finding $record): ?string => $record->current_run_id
|
||||||
|
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
||||||
|
: null)
|
||||||
|
->openUrlInNewTab(),
|
||||||
|
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||||
|
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
||||||
|
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||||
|
])
|
||||||
|
->columns(2)
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Diff')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('settings_diff')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.normalized-diff')
|
||||||
|
->state(function (Finding $record): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
|
$baselineVersion = is_numeric($baselineId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$currentVersion = is_numeric($currentId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||||
|
|
||||||
|
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
|
||||||
|
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
|
||||||
|
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
|
||||||
|
|
||||||
|
if (($addedCount + $removedCount + $changedCount) === 0) {
|
||||||
|
Arr::set(
|
||||||
|
$diff,
|
||||||
|
'summary.message',
|
||||||
|
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $diff;
|
||||||
|
})
|
||||||
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
ViewEntry::make('scope_tags_diff')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.scope-tags-diff')
|
||||||
|
->state(function (Finding $record): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
|
$baselineVersion = is_numeric($baselineId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$currentVersion = is_numeric($currentId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
||||||
|
})
|
||||||
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
ViewEntry::make('assignments_diff')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.assignments-diff')
|
||||||
|
->state(function (Finding $record): array {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant) {
|
||||||
|
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||||
|
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||||
|
|
||||||
|
$baselineVersion = is_numeric($baselineId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$currentVersion = is_numeric($currentId)
|
||||||
|
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
||||||
|
})
|
||||||
|
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->collapsed()
|
||||||
|
->columnSpanFull(),
|
||||||
|
|
||||||
|
Section::make('Evidence (Sanitized)')
|
||||||
|
->schema([
|
||||||
|
ViewEntry::make('evidence_jsonb')
|
||||||
|
->label('')
|
||||||
|
->view('filament.infolists.entries.snapshot-json')
|
||||||
|
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
|
||||||
|
->columnSpanFull(),
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->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_display_name')->label('Subject')->placeholder('—'),
|
||||||
|
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->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'),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
Tables\Filters\SelectFilter::make('status')
|
||||||
|
->options([
|
||||||
|
Finding::STATUS_NEW => 'New',
|
||||||
|
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
||||||
|
])
|
||||||
|
->default(Finding::STATUS_NEW),
|
||||||
|
Tables\Filters\SelectFilter::make('finding_type')
|
||||||
|
->options([
|
||||||
|
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||||
|
])
|
||||||
|
->default(Finding::FINDING_TYPE_DRIFT),
|
||||||
|
Tables\Filters\Filter::make('scope_key')
|
||||||
|
->form([
|
||||||
|
TextInput::make('scope_key')
|
||||||
|
->label('Scope key')
|
||||||
|
->placeholder('Inventory selection hash')
|
||||||
|
->maxLength(255),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$scopeKey = $data['scope_key'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($scopeKey) || $scopeKey === '') {
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->where('scope_key', $scopeKey);
|
||||||
|
}),
|
||||||
|
Tables\Filters\Filter::make('run_ids')
|
||||||
|
->label('Run IDs')
|
||||||
|
->form([
|
||||||
|
TextInput::make('baseline_run_id')
|
||||||
|
->label('Baseline run id')
|
||||||
|
->numeric(),
|
||||||
|
TextInput::make('current_run_id')
|
||||||
|
->label('Current run id')
|
||||||
|
->numeric(),
|
||||||
|
])
|
||||||
|
->query(function (Builder $query, array $data): Builder {
|
||||||
|
$baselineRunId = $data['baseline_run_id'] ?? null;
|
||||||
|
if (is_numeric($baselineRunId)) {
|
||||||
|
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRunId = $data['current_run_id'] ?? null;
|
||||||
|
if (is_numeric($currentRunId)) {
|
||||||
|
$query->where('current_run_id', (int) $currentRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('acknowledge')
|
||||||
|
->label('Acknowledge')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
||||||
|
->authorize(function (Finding $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->can('update', $record);
|
||||||
|
})
|
||||||
|
->action(function (Finding $record): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Finding belongs to a different tenant')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->acknowledge($user);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Finding acknowledged')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\ViewAction::make(),
|
||||||
|
])
|
||||||
|
->bulkActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
BulkAction::make('acknowledge_selected')
|
||||||
|
->label('Acknowledge selected')
|
||||||
|
->icon('heroicon-o-check')
|
||||||
|
->color('gray')
|
||||||
|
->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);
|
||||||
|
})
|
||||||
|
->authorizeIndividualRecords('update')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Collection $records): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant || ! $user instanceof User) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstRecord = $records->first();
|
||||||
|
if ($firstRecord instanceof Finding) {
|
||||||
|
Gate::authorize('update', $firstRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
$acknowledgedCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
if (! $record instanceof Finding) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record->status !== Finding::STATUS_NEW) {
|
||||||
|
$skippedCount++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->acknowledge($user);
|
||||||
|
$acknowledgedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
||||||
|
if ($skippedCount > 0) {
|
||||||
|
$body .= " Skipped {$skippedCount}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Bulk acknowledge completed')
|
||||||
|
->body($body)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
})
|
||||||
|
->deselectRecordsAfterCompletion(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantId = Tenant::current()->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->addSelect([
|
||||||
|
'subject_display_name' => InventoryItem::query()
|
||||||
|
->select('display_name')
|
||||||
|
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
||||||
|
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
||||||
|
->limit(1),
|
||||||
|
])
|
||||||
|
->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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/Filament/Resources/FindingResource/Pages/ListFindings.php
Normal file
171
app/Filament/Resources/FindingResource/Pages/ListFindings.php
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
11
app/Filament/Resources/FindingResource/Pages/ViewFinding.php
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\FindingResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewFinding extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = FindingResource::class;
|
||||||
|
}
|
||||||
104
app/Jobs/GenerateDriftFindingsJob.php
Normal file
104
app/Jobs/GenerateDriftFindingsJob.php
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\BulkOperationService;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class GenerateDriftFindingsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public int $baselineRunId,
|
||||||
|
public int $currentRunId,
|
||||||
|
public string $scopeKey,
|
||||||
|
public int $bulkOperationRunId,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
|
||||||
|
{
|
||||||
|
Log::info('GenerateDriftFindingsJob: started', [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
|
'current_run_id' => $this->currentRunId,
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new RuntimeException('Tenant not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
||||||
|
if (! $baseline instanceof InventorySyncRun) {
|
||||||
|
throw new RuntimeException('Baseline run not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = InventorySyncRun::query()->find($this->currentRunId);
|
||||||
|
if (! $current instanceof InventorySyncRun) {
|
||||||
|
throw new RuntimeException('Current run not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($this->bulkOperationRunId);
|
||||||
|
|
||||||
|
if (! $run instanceof BulkOperationRun) {
|
||||||
|
throw new RuntimeException('Bulk operation run not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bulkOperationService->start($run);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$created = $generator->generate(
|
||||||
|
tenant: $tenant,
|
||||||
|
baseline: $baseline,
|
||||||
|
current: $current,
|
||||||
|
scopeKey: $this->scopeKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info('GenerateDriftFindingsJob: completed', [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
|
'current_run_id' => $this->currentRunId,
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||||
|
'created_findings_count' => $created,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bulkOperationService->recordSuccess($run);
|
||||||
|
$bulkOperationService->complete($run);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('GenerateDriftFindingsJob: failed', [
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
|
'current_run_id' => $this->currentRunId,
|
||||||
|
'scope_key' => $this->scopeKey,
|
||||||
|
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$bulkOperationService->fail($run, $e->getMessage());
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Models/Finding.php
Normal file
67
app/Models/Finding.php
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
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 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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Policies/FindingPolicy.php
Normal file
66
app/Policies/FindingPolicy.php
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\TenantRole;
|
||||||
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
|
||||||
|
class FindingPolicy
|
||||||
|
{
|
||||||
|
use HandlesAuthorization;
|
||||||
|
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,10 +3,18 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\EntraGroup;
|
||||||
|
use App\Models\EntraGroupSyncRun;
|
||||||
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Policies\BackupSchedulePolicy;
|
use App\Policies\BackupSchedulePolicy;
|
||||||
|
use App\Policies\BulkOperationRunPolicy;
|
||||||
|
use App\Policies\EntraGroupPolicy;
|
||||||
|
use App\Policies\EntraGroupSyncRunPolicy;
|
||||||
|
use App\Policies\FindingPolicy;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\MicrosoftGraphClient;
|
use App\Services\Graph\MicrosoftGraphClient;
|
||||||
use App\Services\Graph\NullGraphClient;
|
use App\Services\Graph\NullGraphClient;
|
||||||
@ -108,7 +116,8 @@ public function boot(): void
|
|||||||
|
|
||||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||||
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
||||||
Gate::policy(\App\Models\EntraGroupSyncRun::class, \App\Policies\EntraGroupSyncRunPolicy::class);
|
Gate::policy(Finding::class, FindingPolicy::class);
|
||||||
Gate::policy(\App\Models\EntraGroup::class, \App\Policies\EntraGroupPolicy::class);
|
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||||
|
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/Services/Drift/DriftEvidence.php
Normal file
31
app/Services/Drift/DriftEvidence.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
class DriftEvidence
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
304
app/Services/Drift/DriftFindingDiffBuilder.php
Normal file
304
app/Services/Drift/DriftFindingDiffBuilder.php
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
|
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||||
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||||
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||||
|
use App\Services\Intune\VersionDiff;
|
||||||
|
|
||||||
|
class DriftFindingDiffBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettingsNormalizer $settingsNormalizer,
|
||||||
|
private readonly AssignmentsNormalizer $assignmentsNormalizer,
|
||||||
|
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||||
|
private readonly VersionDiff $versionDiff,
|
||||||
|
private readonly EntraGroupLabelResolver $groupLabelResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildSettingsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array
|
||||||
|
{
|
||||||
|
$policyType = $currentVersion?->policy_type ?? $baselineVersion?->policy_type ?? '';
|
||||||
|
$platform = $currentVersion?->platform ?? $baselineVersion?->platform;
|
||||||
|
|
||||||
|
$from = $baselineVersion
|
||||||
|
? $this->settingsNormalizer->normalizeForDiff(is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [], (string) $policyType, $platform)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$to = $currentVersion
|
||||||
|
? $this->settingsNormalizer->normalizeForDiff(is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [], (string) $policyType, $platform)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$result = $this->versionDiff->compare($from, $to);
|
||||||
|
$result['policy_type'] = $policyType;
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildAssignmentsDiff(Tenant $tenant, ?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, int $limit = 200): array
|
||||||
|
{
|
||||||
|
$baseline = $baselineVersion ? $this->assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments) : [];
|
||||||
|
$current = $currentVersion ? $this->assignmentsNormalizer->normalizeForDiff($currentVersion->assignments) : [];
|
||||||
|
|
||||||
|
$baselineMap = [];
|
||||||
|
foreach ($baseline as $row) {
|
||||||
|
$baselineMap[$row['key']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentMap = [];
|
||||||
|
foreach ($current as $row) {
|
||||||
|
$currentMap[$row['key']] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allKeys = array_values(array_unique(array_merge(array_keys($baselineMap), array_keys($currentMap))));
|
||||||
|
sort($allKeys);
|
||||||
|
|
||||||
|
$added = [];
|
||||||
|
$removed = [];
|
||||||
|
$changed = [];
|
||||||
|
|
||||||
|
foreach ($allKeys as $key) {
|
||||||
|
$from = $baselineMap[$key] ?? null;
|
||||||
|
$to = $currentMap[$key] ?? null;
|
||||||
|
|
||||||
|
if ($from === null && is_array($to)) {
|
||||||
|
$added[] = $to;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($to === null && is_array($from)) {
|
||||||
|
$removed[] = $from;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_array($from) || ! is_array($to)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$diffFields = [
|
||||||
|
'filter_type',
|
||||||
|
'filter_id',
|
||||||
|
'intent',
|
||||||
|
'mode',
|
||||||
|
];
|
||||||
|
|
||||||
|
$fieldChanges = [];
|
||||||
|
|
||||||
|
foreach ($diffFields as $field) {
|
||||||
|
$fromValue = $from[$field] ?? null;
|
||||||
|
$toValue = $to[$field] ?? null;
|
||||||
|
|
||||||
|
if ($fromValue !== $toValue) {
|
||||||
|
$fieldChanges[$field] = [
|
||||||
|
'from' => $fromValue,
|
||||||
|
'to' => $toValue,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fieldChanges !== []) {
|
||||||
|
$changed[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'include_exclude' => $to['include_exclude'],
|
||||||
|
'target_type' => $to['target_type'],
|
||||||
|
'target_id' => $to['target_id'],
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'changes' => $fieldChanges,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$truncated = false;
|
||||||
|
|
||||||
|
$total = count($added) + count($removed) + count($changed);
|
||||||
|
if ($total > $limit) {
|
||||||
|
$truncated = true;
|
||||||
|
|
||||||
|
$budget = $limit;
|
||||||
|
|
||||||
|
$changed = array_slice($changed, 0, min(count($changed), $budget));
|
||||||
|
$budget -= count($changed);
|
||||||
|
|
||||||
|
$added = array_slice($added, 0, min(count($added), $budget));
|
||||||
|
$budget -= count($added);
|
||||||
|
|
||||||
|
$removed = array_slice($removed, 0, min(count($removed), $budget));
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = $this->groupLabelsForDiff($tenant, $added, $removed, $changed);
|
||||||
|
|
||||||
|
$decorateAssignment = function (array $row) use ($labels): array {
|
||||||
|
$row['target_label'] = $this->targetLabel($row, $labels);
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
|
||||||
|
$decorateChanged = function (array $row) use ($decorateAssignment): array {
|
||||||
|
$row['from'] = is_array($row['from'] ?? null) ? $decorateAssignment($row['from']) : $row['from'];
|
||||||
|
$row['to'] = is_array($row['to'] ?? null) ? $decorateAssignment($row['to']) : $row['to'];
|
||||||
|
$row['target_label'] = is_array($row['to'] ?? null) ? ($row['to']['target_label'] ?? null) : null;
|
||||||
|
|
||||||
|
return $row;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'added' => count($added),
|
||||||
|
'removed' => count($removed),
|
||||||
|
'changed' => count($changed),
|
||||||
|
'message' => sprintf('%d added, %d removed, %d changed', count($added), count($removed), count($changed)),
|
||||||
|
'truncated' => $truncated,
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'added' => array_map($decorateAssignment, $added),
|
||||||
|
'removed' => array_map($decorateAssignment, $removed),
|
||||||
|
'changed' => array_map($decorateChanged, $changed),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function buildScopeTagsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array
|
||||||
|
{
|
||||||
|
$baselineIds = $baselineVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags) ?? []) : [];
|
||||||
|
$currentIds = $currentVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags) ?? []) : [];
|
||||||
|
|
||||||
|
$baselineLabels = $baselineVersion ? $this->scopeTagsNormalizer->labelsById($baselineVersion->scope_tags) : [];
|
||||||
|
$currentLabels = $currentVersion ? $this->scopeTagsNormalizer->labelsById($currentVersion->scope_tags) : [];
|
||||||
|
|
||||||
|
$baselineSet = array_fill_keys($baselineIds, true);
|
||||||
|
$currentSet = array_fill_keys($currentIds, true);
|
||||||
|
|
||||||
|
$addedIds = array_values(array_diff($currentIds, $baselineIds));
|
||||||
|
$removedIds = array_values(array_diff($baselineIds, $currentIds));
|
||||||
|
|
||||||
|
sort($addedIds);
|
||||||
|
sort($removedIds);
|
||||||
|
|
||||||
|
$decorate = static function (array $ids, array $labels): array {
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (! is_string($id) || $id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $labels[$id] ?? ($id === '0' ? 'Default' : $id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => [
|
||||||
|
'added' => count($addedIds),
|
||||||
|
'removed' => count($removedIds),
|
||||||
|
'changed' => 0,
|
||||||
|
'message' => sprintf('%d added, %d removed', count($addedIds), count($removedIds)),
|
||||||
|
'baseline_count' => count($baselineSet),
|
||||||
|
'current_count' => count($currentSet),
|
||||||
|
],
|
||||||
|
'added' => $decorate($addedIds, $currentLabels),
|
||||||
|
'removed' => $decorate($removedIds, $baselineLabels),
|
||||||
|
'baseline' => $decorate($baselineIds, $baselineLabels),
|
||||||
|
'current' => $decorate($currentIds, $currentLabels),
|
||||||
|
'changed' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $added
|
||||||
|
* @param array<int, array<string, mixed>> $removed
|
||||||
|
* @param array<int, array<string, mixed>> $changed
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function groupLabelsForDiff(Tenant $tenant, array $added, array $removed, array $changed): array
|
||||||
|
{
|
||||||
|
$groupIds = [];
|
||||||
|
|
||||||
|
foreach ([$added, $removed] as $items) {
|
||||||
|
foreach ($items as $row) {
|
||||||
|
$targetType = $row['target_type'] ?? null;
|
||||||
|
$targetId = $row['target_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($targetType, 'groupassignmenttarget')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds[] = $targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($changed as $row) {
|
||||||
|
$targetType = $row['target_type'] ?? null;
|
||||||
|
$targetId = $row['target_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_contains($targetType, 'groupassignmenttarget')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds[] = $targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupIds = array_values(array_unique($groupIds));
|
||||||
|
|
||||||
|
if ($groupIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->groupLabelResolver->resolveMany($tenant, $groupIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $assignment
|
||||||
|
* @param array<string, string> $groupLabels
|
||||||
|
*/
|
||||||
|
private function targetLabel(array $assignment, array $groupLabels): string
|
||||||
|
{
|
||||||
|
$targetType = $assignment['target_type'] ?? null;
|
||||||
|
$targetId = $assignment['target_id'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||||
|
return 'Unknown target';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
|
||||||
|
return 'All devices';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'allusersassignmenttarget')) {
|
||||||
|
return 'All users';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'groupassignmenttarget')) {
|
||||||
|
return $groupLabels[$targetId] ?? EntraGroupLabelResolver::formatLabel(null, $targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('%s (%s)', $targetType, $targetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
305
app/Services/Drift/DriftFindingGenerator.php
Normal file
305
app/Services/Drift/DriftFindingGenerator.php
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||||
|
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||||
|
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
||||||
|
{
|
||||||
|
if (! $baseline->finished_at || ! $current->finished_at) {
|
||||||
|
throw new RuntimeException('Baseline/current run must be finished.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $selection */
|
||||||
|
$selection = is_array($current->selection_payload) ? $current->selection_payload : [];
|
||||||
|
|
||||||
|
$policyTypes = Arr::get($selection, 'policy_types');
|
||||||
|
if (! is_array($policyTypes)) {
|
||||||
|
$policyTypes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
|
||||||
|
Policy::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->whereIn('policy_type', $policyTypes)
|
||||||
|
->orderBy('id')
|
||||||
|
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$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_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => (string) $policy->external_id,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'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_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'assignment',
|
||||||
|
'subject_external_id' => (string) $policy->external_id,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'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_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'scope_tag',
|
||||||
|
'subject_external_id' => (string) $policy->external_id,
|
||||||
|
'severity' => Finding::SEVERITY_MEDIUM,
|
||||||
|
'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, InventorySyncRun $run): ?PolicyVersion
|
||||||
|
{
|
||||||
|
if (! $run->finished_at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return PolicyVersion::query()
|
||||||
|
->where('tenant_id', $policy->tenant_id)
|
||||||
|
->where('policy_id', $policy->getKey())
|
||||||
|
->where('captured_at', '<=', $run->finished_at)
|
||||||
|
->latest('captured_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Services/Drift/DriftHasher.php
Normal file
101
app/Services/Drift/DriftHasher.php
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
class DriftHasher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $volatileKeys
|
||||||
|
*/
|
||||||
|
public function hashNormalized(mixed $value, array $volatileKeys = [
|
||||||
|
'@odata.context',
|
||||||
|
'@odata.etag',
|
||||||
|
'createdDateTime',
|
||||||
|
'lastModifiedDateTime',
|
||||||
|
'modifiedDateTime',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
]): string
|
||||||
|
{
|
||||||
|
$normalized = $this->normalizeValue($value, $volatileKeys);
|
||||||
|
|
||||||
|
return hash('sha256', json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fingerprint(
|
||||||
|
int $tenantId,
|
||||||
|
string $scopeKey,
|
||||||
|
string $subjectType,
|
||||||
|
string $subjectExternalId,
|
||||||
|
string $changeType,
|
||||||
|
string $baselineHash,
|
||||||
|
string $currentHash,
|
||||||
|
): string {
|
||||||
|
$parts = [
|
||||||
|
(string) $tenantId,
|
||||||
|
$this->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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $volatileKeys
|
||||||
|
*/
|
||||||
|
private function normalizeValue(mixed $value, array $volatileKeys): mixed
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
if ($this->isList($value)) {
|
||||||
|
$items = array_map(fn ($item) => $this->normalizeValue($item, $volatileKeys), $value);
|
||||||
|
|
||||||
|
usort($items, function ($a, $b): int {
|
||||||
|
return strcmp(
|
||||||
|
json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||||
|
json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (is_string($key) && in_array($key, $volatileKeys, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[$key] = $this->normalizeValue($item, $volatileKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($result);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value)) {
|
||||||
|
return trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isList(array $value): bool
|
||||||
|
{
|
||||||
|
if ($value === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_keys($value) === range(0, count($value) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/Drift/DriftRunSelector.php
Normal file
40
app/Services/Drift/DriftRunSelector.php
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
|
class DriftRunSelector
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null
|
||||||
|
*/
|
||||||
|
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
||||||
|
{
|
||||||
|
$runs = InventorySyncRun::query()
|
||||||
|
->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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/Services/Drift/DriftScopeKey.php
Normal file
13
app/Services/Drift/DriftScopeKey.php
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift;
|
||||||
|
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
|
||||||
|
class DriftScopeKey
|
||||||
|
{
|
||||||
|
public function fromRun(InventorySyncRun $run): string
|
||||||
|
{
|
||||||
|
return (string) $run->selection_hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
app/Services/Drift/Normalizers/AssignmentsNormalizer.php
Normal file
113
app/Services/Drift/Normalizers/AssignmentsNormalizer.php
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift\Normalizers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class AssignmentsNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array{key:string,include_exclude:string,target_type:string,target_id:string,filter_type:string,filter_id:?string,intent:?string,mode:?string}>
|
||||||
|
*/
|
||||||
|
public function normalizeForDiff(mixed $assignments): array
|
||||||
|
{
|
||||||
|
if (! is_array($assignments)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
if (! is_array($assignment)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $assignment['target'] ?? null;
|
||||||
|
if (! is_array($target)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawType = $target['@odata.type'] ?? null;
|
||||||
|
$targetType = $this->normalizeOdataType(is_string($rawType) ? $rawType : '');
|
||||||
|
|
||||||
|
$includeExclude = str_contains($targetType, 'exclusion') ? 'exclude' : 'include';
|
||||||
|
$targetId = $this->extractTargetId($targetType, $target);
|
||||||
|
|
||||||
|
if ($targetId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||||
|
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none';
|
||||||
|
|
||||||
|
$intent = $assignment['intent'] ?? null;
|
||||||
|
$mode = $assignment['mode'] ?? null;
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'key' => implode('|', [
|
||||||
|
$includeExclude,
|
||||||
|
$targetType,
|
||||||
|
$targetId,
|
||||||
|
]),
|
||||||
|
'include_exclude' => $includeExclude,
|
||||||
|
'target_type' => $targetType,
|
||||||
|
'target_id' => $targetId,
|
||||||
|
'filter_type' => is_string($filterType) && $filterType !== '' ? strtolower(trim($filterType)) : 'none',
|
||||||
|
'filter_id' => is_string($filterId) && $filterId !== '' ? $filterId : null,
|
||||||
|
'intent' => is_string($intent) && $intent !== '' ? strtolower(trim($intent)) : null,
|
||||||
|
'mode' => is_string($mode) && $mode !== '' ? strtolower(trim($mode)) : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, function (array $a, array $b): int {
|
||||||
|
return strcmp($a['key'], $b['key']);
|
||||||
|
});
|
||||||
|
|
||||||
|
return array_values($rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOdataType(string $odataType): string
|
||||||
|
{
|
||||||
|
$value = trim($odataType);
|
||||||
|
$value = ltrim($value, '#');
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($value, '.')) {
|
||||||
|
$value = (string) strrchr($value, '.');
|
||||||
|
$value = ltrim($value, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtolower(trim($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $target
|
||||||
|
*/
|
||||||
|
private function extractTargetId(string $targetType, array $target): string
|
||||||
|
{
|
||||||
|
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
|
||||||
|
return 'all_devices';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($targetType, 'allusersassignmenttarget')) {
|
||||||
|
return 'all_users';
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupId = Arr::get($target, 'groupId');
|
||||||
|
if (is_string($groupId) && $groupId !== '') {
|
||||||
|
return $groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$collectionId = Arr::get($target, 'collectionId');
|
||||||
|
if (is_string($collectionId) && $collectionId !== '') {
|
||||||
|
return $collectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Services/Drift/Normalizers/ScopeTagsNormalizer.php
Normal file
136
app/Services/Drift/Normalizers/ScopeTagsNormalizer.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift\Normalizers;
|
||||||
|
|
||||||
|
class ScopeTagsNormalizer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function normalizeIds(mixed $scopeTags): array
|
||||||
|
{
|
||||||
|
return $this->normalizeIdsForHash($scopeTags) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For drift hashing/comparison we need stable, reliable IDs.
|
||||||
|
*
|
||||||
|
* Legacy policy versions may have only `names` without `ids`. In that case we:
|
||||||
|
* - infer `Default` as id `0`
|
||||||
|
* - otherwise return null (unknown/unreliable; should not create drift)
|
||||||
|
*
|
||||||
|
* @return array<int, string>|null
|
||||||
|
*/
|
||||||
|
public function normalizeIdsForHash(mixed $scopeTags): ?array
|
||||||
|
{
|
||||||
|
if (! is_array($scopeTags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $scopeTags['ids'] ?? null;
|
||||||
|
if (is_array($ids)) {
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
if (! is_string($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized[] = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = array_values(array_unique($normalized));
|
||||||
|
sort($normalized);
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
$names = $scopeTags['names'] ?? null;
|
||||||
|
if (! is_array($names) || $names === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedNames = [];
|
||||||
|
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (! is_string($name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim($name);
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedNames[] = strtolower($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedNames = array_values(array_unique($normalizedNames));
|
||||||
|
sort($normalizedNames);
|
||||||
|
|
||||||
|
if ($normalizedNames === ['default']) {
|
||||||
|
return ['0'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function labelsById(mixed $scopeTags): array
|
||||||
|
{
|
||||||
|
if (! is_array($scopeTags)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = is_array($scopeTags['ids'] ?? null) ? $scopeTags['ids'] : null;
|
||||||
|
$names = is_array($scopeTags['names'] ?? null) ? $scopeTags['names'] : [];
|
||||||
|
|
||||||
|
if (! is_array($ids)) {
|
||||||
|
$inferred = $this->normalizeIdsForHash($scopeTags);
|
||||||
|
|
||||||
|
if ($inferred === ['0'] && $names !== []) {
|
||||||
|
return ['0' => 'Default'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [];
|
||||||
|
|
||||||
|
foreach ($ids as $index => $id) {
|
||||||
|
if (! is_string($id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = trim($id);
|
||||||
|
|
||||||
|
if ($id === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $names[$index] ?? '';
|
||||||
|
$name = is_string($name) ? trim($name) : '';
|
||||||
|
|
||||||
|
if ($name === '') {
|
||||||
|
$name = $id === '0' ? 'Default' : $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists($id, $labels) || $labels[$id] === $id) {
|
||||||
|
$labels[$id] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($labels);
|
||||||
|
|
||||||
|
return $labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Services/Drift/Normalizers/SettingsNormalizer.php
Normal file
19
app/Services/Drift/Normalizers/SettingsNormalizer.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Drift\Normalizers;
|
||||||
|
|
||||||
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
|
|
||||||
|
class SettingsNormalizer
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PolicyNormalizer $policyNormalizer) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $snapshot
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function normalizeForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||||
|
{
|
||||||
|
return $this->policyNormalizer->flattenForDiff($snapshot ?? [], $policyType, $platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,13 +6,13 @@
|
|||||||
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Read and write Intune device configuration policies.',
|
'description' => 'Read and write Intune device configuration policies.',
|
||||||
'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization'],
|
'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization', 'drift'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'DeviceManagementConfiguration.Read.All',
|
'key' => 'DeviceManagementConfiguration.Read.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Read Intune device configuration policies (least-privilege for inventory).',
|
'description' => 'Read Intune device configuration policies (least-privilege for inventory).',
|
||||||
'features' => ['policy-sync', 'backup', 'settings-normalization'],
|
'features' => ['policy-sync', 'backup', 'settings-normalization', 'drift'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'DeviceManagementApps.ReadWrite.All',
|
'key' => 'DeviceManagementApps.ReadWrite.All',
|
||||||
@ -72,7 +72,7 @@
|
|||||||
'key' => 'Group.Read.All',
|
'key' => 'Group.Read.All',
|
||||||
'type' => 'application',
|
'type' => 'application',
|
||||||
'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.',
|
'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.',
|
||||||
'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache'],
|
'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache', 'drift'],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'key' => 'DeviceManagementScripts.ReadWrite.All',
|
'key' => 'DeviceManagementScripts.ReadWrite.All',
|
||||||
|
|||||||
37
database/factories/FindingFactory.php
Normal file
37
database/factories/FindingFactory.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Finding>
|
||||||
|
*/
|
||||||
|
class FindingFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('findings', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
@php
|
||||||
|
$diff = $getState() ?? [];
|
||||||
|
$summary = $diff['summary'] ?? [];
|
||||||
|
|
||||||
|
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||||
|
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||||
|
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||||
|
|
||||||
|
$renderRow = static function (array $row): array {
|
||||||
|
return [
|
||||||
|
'include_exclude' => (string) ($row['include_exclude'] ?? 'include'),
|
||||||
|
'target_label' => (string) ($row['target_label'] ?? 'Unknown target'),
|
||||||
|
'filter_type' => (string) ($row['filter_type'] ?? 'none'),
|
||||||
|
'filter_id' => $row['filter_id'] ?? null,
|
||||||
|
'intent' => $row['intent'] ?? null,
|
||||||
|
'mode' => $row['mode'] ?? null,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Assignments diff"
|
||||||
|
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
{{ (int) ($summary['added'] ?? 0) }} added
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="warning">
|
||||||
|
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (($summary['truncated'] ?? false) === true)
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Truncated to {{ (int) ($summary['limit'] ?? 0) }} items
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($changed !== [])
|
||||||
|
<x-filament::section heading="Changed" collapsible>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach ($changed as $row)
|
||||||
|
@php
|
||||||
|
$to = is_array($row['to'] ?? null) ? $renderRow($row['to']) : $renderRow([]);
|
||||||
|
$from = is_array($row['from'] ?? null) ? $renderRow($row['from']) : $renderRow([]);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ $to['target_label'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 grid gap-2 text-sm md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">From</div>
|
||||||
|
<div class="mt-1 space-y-1">
|
||||||
|
<div>Type: {{ $from['include_exclude'] }}</div>
|
||||||
|
<div>Filter: {{ $from['filter_type'] }}@if($from['filter_id']) ({{ $from['filter_id'] }})@endif</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">To</div>
|
||||||
|
<div class="mt-1 space-y-1">
|
||||||
|
<div>Type: {{ $to['include_exclude'] }}</div>
|
||||||
|
<div>Filter: {{ $to['filter_type'] }}@if($to['filter_id']) ({{ $to['filter_id'] }})@endif</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($added !== [])
|
||||||
|
<x-filament::section heading="Added" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($added as $row)
|
||||||
|
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $row['target_label'] }}</div>
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($removed !== [])
|
||||||
|
<x-filament::section heading="Removed" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($removed as $row)
|
||||||
|
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $row['target_label'] }}</div>
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
@php
|
||||||
|
$diff = $getState() ?? [];
|
||||||
|
$summary = $diff['summary'] ?? [];
|
||||||
|
|
||||||
|
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||||
|
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||||
|
$baseline = is_array($diff['baseline'] ?? null) ? $diff['baseline'] : [];
|
||||||
|
$current = is_array($diff['current'] ?? null) ? $diff['current'] : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<x-filament::section
|
||||||
|
heading="Scope tags diff"
|
||||||
|
:description="$summary['message'] ?? sprintf('%d added, %d removed', $summary['added'] ?? 0, $summary['removed'] ?? 0)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
{{ (int) ($summary['added'] ?? 0) }} added
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Baseline: {{ (int) ($summary['baseline_count'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Current: {{ (int) ($summary['current_count'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@if ($added !== [])
|
||||||
|
<x-filament::section heading="Added" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($added as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($removed !== [])
|
||||||
|
<x-filament::section heading="Removed" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($removed as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($current !== [])
|
||||||
|
<x-filament::section heading="Current" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($current as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($baseline !== [])
|
||||||
|
<x-filament::section heading="Baseline" collapsible :collapsed="true">
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach ($baseline as $row)
|
||||||
|
@php
|
||||||
|
$name = (string) ($row['name'] ?? 'Unknown');
|
||||||
|
$id = (string) ($row['id'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<div class="font-medium">{{ $name }}</div>
|
||||||
|
@if ($id !== '')
|
||||||
|
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
107
resources/views/filament/pages/drift-landing.blade.php
Normal file
107
resources/views/filament/pages/drift-landing.blade.php
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<x-filament::page>
|
||||||
|
<x-filament::section>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Review new drift findings between the last two inventory sync runs for the current scope.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($scopeKey))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Scope: {{ $scopeKey }}
|
||||||
|
@if ($baselineRunId && $currentRunId)
|
||||||
|
· Baseline
|
||||||
|
@if ($this->getBaselineRunUrl())
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getBaselineRunUrl() }}">
|
||||||
|
#{{ $baselineRunId }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
#{{ $baselineRunId }}
|
||||||
|
@endif
|
||||||
|
@if (filled($baselineFinishedAt))
|
||||||
|
({{ $baselineFinishedAt }})
|
||||||
|
@endif
|
||||||
|
· Current
|
||||||
|
@if ($this->getCurrentRunUrl())
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getCurrentRunUrl() }}">
|
||||||
|
#{{ $currentRunId }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
#{{ $currentRunId }}
|
||||||
|
@endif
|
||||||
|
@if (filled($currentFinishedAt))
|
||||||
|
({{ $currentFinishedAt }})
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($state === 'blocked')
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Blocked
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'generating')
|
||||||
|
<x-filament::badge color="warning">
|
||||||
|
Generating
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Drift generation has been queued. Refresh this page once it finishes.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if ($this->getBulkRunUrl())
|
||||||
|
<div class="text-sm">
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
||||||
|
View run #{{ $bulkOperationRunId }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'error')
|
||||||
|
<x-filament::badge color="danger">
|
||||||
|
Error
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($this->getBulkRunUrl())
|
||||||
|
<div class="text-sm">
|
||||||
|
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
||||||
|
View run #{{ $bulkOperationRunId }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@elseif ($state === 'ready')
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<x-filament::badge color="success">
|
||||||
|
New: {{ (int) ($statusCounts['new'] ?? 0) }}
|
||||||
|
</x-filament::badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (filled($message))
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{{ $message }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<x-filament::badge color="gray">
|
||||||
|
Ready
|
||||||
|
</x-filament::badge>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<x-filament::button tag="a" :href="$this->getFindingsUrl()">
|
||||||
|
Findings
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
</x-filament::page>
|
||||||
35
specs/044-drift-mvp/checklists/requirements.md
Normal file
35
specs/044-drift-mvp/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Drift MVP (044)
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to implementation
|
||||||
|
**Created**: 2026-01-12
|
||||||
|
**Feature**: [specs/044-drift-mvp/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs) (spec.md contains scenarios/rules/states/acceptance only)
|
||||||
|
- [x] Focused on user value and business needs (spec.md: Purpose, User Scenarios, Acceptance Criteria)
|
||||||
|
- [x] Written for non-technical stakeholders (spec.md uses plain language; avoids code/framework terms)
|
||||||
|
- [x] All mandatory sections completed (spec.md includes Purpose, User Scenarios, Rules, Acceptance Criteria)
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain (spec.md: no "[NEEDS CLARIFICATION]" markers)
|
||||||
|
- [x] Requirements are testable and unambiguous (spec.md: Rules + Acceptance Criteria)
|
||||||
|
- [x] Success criteria are measurable (spec.md: Acceptance Criteria)
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details) (spec.md: Acceptance Criteria)
|
||||||
|
- [x] All acceptance scenarios are defined (spec.md: Scenario 1/2/3)
|
||||||
|
- [x] Edge cases are identified (spec.md: blocked state; error state; acknowledgement per comparison)
|
||||||
|
- [x] Scope is clearly bounded (spec.md: Rules → Coverage (MVP))
|
||||||
|
- [x] Dependencies and assumptions identified (spec.md: Rules → UI states; Run tracking)
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria (spec.md: Rules + Acceptance Criteria)
|
||||||
|
- [x] User scenarios cover primary flows (spec.md: Scenario 1/2/3)
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria (spec.md: Acceptance Criteria are measurable and testable)
|
||||||
|
- [x] No implementation details leak into specification (spec.md avoids implementation and names a generic “persisted run record” only)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`.
|
||||||
|
- Constitution gate: this checklist must exist for features that change runtime behavior.
|
||||||
167
specs/044-drift-mvp/contracts/admin-findings.openapi.yaml
Normal file
167
specs/044-drift-mvp/contracts/admin-findings.openapi.yaml
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Admin Findings API (Internal)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Internal contracts for the generic Findings pipeline.
|
||||||
|
Drift MVP is the first generator (finding_type=drift).
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: /admin/api
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/findings:
|
||||||
|
get:
|
||||||
|
summary: List findings
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: finding_type
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [drift, audit, compare]
|
||||||
|
- in: query
|
||||||
|
name: status
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [new, acknowledged]
|
||||||
|
- in: query
|
||||||
|
name: scope_key
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: current_run_id
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/Finding'
|
||||||
|
|
||||||
|
/findings/{id}:
|
||||||
|
get:
|
||||||
|
summary: Get finding detail
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/Finding'
|
||||||
|
|
||||||
|
/findings/{id}/acknowledge:
|
||||||
|
post:
|
||||||
|
summary: Acknowledge a finding
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/Finding'
|
||||||
|
|
||||||
|
/drift/generate:
|
||||||
|
post:
|
||||||
|
summary: Generate drift findings (async)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
scope_key:
|
||||||
|
type: string
|
||||||
|
description: Inventory selection hash
|
||||||
|
required: [scope_key]
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/DriftGenerateAccepted'
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
DriftGenerateAccepted:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
bulk_operation_run_id:
|
||||||
|
type: integer
|
||||||
|
description: Canonical async run record (status/errors/idempotency)
|
||||||
|
scope_key:
|
||||||
|
type: string
|
||||||
|
baseline_run_id:
|
||||||
|
type: integer
|
||||||
|
current_run_id:
|
||||||
|
type: integer
|
||||||
|
required: [bulk_operation_run_id, scope_key, baseline_run_id, current_run_id]
|
||||||
|
|
||||||
|
Finding:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
finding_type:
|
||||||
|
type: string
|
||||||
|
enum: [drift, audit, compare]
|
||||||
|
tenant_id:
|
||||||
|
type: integer
|
||||||
|
scope_key:
|
||||||
|
type: string
|
||||||
|
baseline_run_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
current_run_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
fingerprint:
|
||||||
|
type: string
|
||||||
|
subject_type:
|
||||||
|
type: string
|
||||||
|
subject_external_id:
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
type: string
|
||||||
|
enum: [low, medium, high]
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [new, acknowledged]
|
||||||
|
acknowledged_at:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
acknowledged_by_user_id:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
evidence_jsonb:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
57
specs/044-drift-mvp/data-model.md
Normal file
57
specs/044-drift-mvp/data-model.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Phase 1 Design: Data Model (044)
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Finding
|
||||||
|
|
||||||
|
New table: `findings`
|
||||||
|
|
||||||
|
**Purpose**: Generic, persisted pipeline for analytic findings (Drift now; Audit/Compare later).
|
||||||
|
|
||||||
|
**Core fields (MVP)**
|
||||||
|
- `id` (pk)
|
||||||
|
- `tenant_id` (fk tenants)
|
||||||
|
- `finding_type` (`drift` in MVP; later `audit`/`compare`)
|
||||||
|
- `scope_key` (string; deterministic; reuse Inventory selection hash)
|
||||||
|
- `baseline_run_id` (nullable fk inventory_sync_runs)
|
||||||
|
- `current_run_id` (nullable fk inventory_sync_runs)
|
||||||
|
- `fingerprint` (string; deterministic)
|
||||||
|
- `subject_type` (string; e.g. policy type)
|
||||||
|
- `subject_external_id` (string; Graph external id)
|
||||||
|
- `severity` (`low|medium|high`; MVP default `medium`)
|
||||||
|
- `status` (`new|acknowledged`)
|
||||||
|
- `acknowledged_at` (nullable)
|
||||||
|
- `acknowledged_by_user_id` (nullable fk users)
|
||||||
|
- `evidence_jsonb` (jsonb; sanitized, small; allowlist)
|
||||||
|
|
||||||
|
**Prepared for later (nullable, out of MVP)**
|
||||||
|
- `rule_id`, `control_id`, `expected_value`, `source`
|
||||||
|
|
||||||
|
## Constraints & Indexes
|
||||||
|
|
||||||
|
**Uniqueness**
|
||||||
|
- Unique: `(tenant_id, fingerprint)`
|
||||||
|
|
||||||
|
**Lookup indexes (suggested)**
|
||||||
|
- `(tenant_id, finding_type, status)`
|
||||||
|
- `(tenant_id, scope_key)`
|
||||||
|
- `(tenant_id, current_run_id)`
|
||||||
|
- `(tenant_id, baseline_run_id)`
|
||||||
|
- `(tenant_id, subject_type, subject_external_id)`
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- `Finding` belongs to `Tenant`.
|
||||||
|
- `Finding` belongs to `User` via `acknowledged_by_user_id`.
|
||||||
|
- `Finding` belongs to `InventorySyncRun` via `baseline_run_id` (nullable) and `current_run_id` (nullable).
|
||||||
|
|
||||||
|
## Evidence shape (MVP allowlist)
|
||||||
|
|
||||||
|
For Drift MVP, `evidence_jsonb` should contain only:
|
||||||
|
- `change_type`
|
||||||
|
- `changed_fields` (list) and/or `change_counts`
|
||||||
|
- `run`:
|
||||||
|
- `baseline_run_id`, `current_run_id`
|
||||||
|
- `baseline_finished_at`, `current_finished_at`
|
||||||
|
|
||||||
|
No raw policy payload dumps; exclude secrets/tokens; exclude volatile fields for hashing.
|
||||||
@ -1,24 +1,115 @@
|
|||||||
# Implementation Plan: Drift MVP
|
# Implementation Plan: Drift MVP (044)
|
||||||
|
|
||||||
**Date**: 2026-01-07
|
**Branch**: `feat/044-drift-mvp` | **Date**: 2026-01-12 | **Spec**: `specs/044-drift-mvp/spec.md`
|
||||||
**Spec**: `specs/044-drift-mvp/spec.md`
|
**Input**: Feature specification from `specs/044-drift-mvp/spec.md`
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Add drift findings generation and UI using inventory and sync run metadata.
|
Introduce a generic, persisted Finding pipeline and implement Drift as the first generator.
|
||||||
|
|
||||||
## Dependencies
|
- Drift compares Inventory Sync Runs for the same selection scope (`scope_key`).
|
||||||
|
- Baseline run = previous successful run for the same scope; comparison run = latest successful run.
|
||||||
|
- Findings are persisted with deterministic fingerprints and support MVP triage (`new` → `acknowledged`).
|
||||||
|
- UI is DB-only for label/name resolution (no render-time Graph calls).
|
||||||
|
- Drift generation is tracked via `BulkOperationRun` for status/errors across refresh and idempotency.
|
||||||
|
|
||||||
- Inventory core + run tracking (Spec 040)
|
## Technical Context
|
||||||
- Inventory UI patterns (Spec 041)
|
|
||||||
|
|
||||||
## Deliverables
|
**Language/Version**: PHP 8.4.x
|
||||||
|
**Framework**: Laravel 12
|
||||||
|
**Admin UI**: Filament v4 + Livewire v3
|
||||||
|
**Storage**: PostgreSQL (JSONB)
|
||||||
|
**Testing**: Pest v4
|
||||||
|
**Target Platform**: Docker (Sail-first local), Dokploy container deployments
|
||||||
|
**Project Type**: Laravel monolith
|
||||||
|
**Performance Goals**:
|
||||||
|
- Drift generation happens async (job), with deterministic output
|
||||||
|
- Drift listing remains filterable and index-backed
|
||||||
|
**Constraints**:
|
||||||
|
- Tenant isolation for all reads/writes
|
||||||
|
- No render-time Graph calls; labels resolved from DB caches
|
||||||
|
- Evidence minimization (sanitized allowlist; no raw payload dumps)
|
||||||
|
- Drift generation is job-only and uses `BulkOperationRun` (`resource=drift`, `action=generate`) as the canonical run record
|
||||||
|
**Scale/Scope**:
|
||||||
|
- Tenants may have large inventories; findings must be indexed for typical filtering
|
||||||
|
|
||||||
- Baseline definition and drift finding generation
|
## Constitution Check
|
||||||
- Drift summary + detail UI
|
|
||||||
- Acknowledge/triage actions
|
|
||||||
|
|
||||||
## Risks
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
- False positives if baseline definition is unclear
|
- Inventory-first: Drift is derived from Inventory Sync Runs and Inventory Items (“last observed” state).
|
||||||
- Data volume for large tenants
|
- Read/write separation: Drift generation is analytical; writes are limited to triage acknowledgement and must be audited + tested.
|
||||||
|
- Graph contract path: Drift UI performs no Graph calls; Graph calls remain isolated in existing Inventory/Graph client layers.
|
||||||
|
- Deterministic capabilities: drift scope derives from existing selection hashing and inventory type registries.
|
||||||
|
- Tenant isolation: all reads/writes tenant-scoped; no cross-tenant leakage.
|
||||||
|
- Automation: drift generation is queued; jobs are deduped/locked per scope+run pair and observable.
|
||||||
|
- Data minimization: store only minimized evidence JSON; logs contain no secrets/tokens.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/044-drift-mvp/
|
||||||
|
├── plan.md # This file (/speckit.plan output)
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ └── admin-findings.openapi.yaml
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Pages/
|
||||||
|
│ │ └── DriftLanding.php # drift landing page (summary + generation status)
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── FindingResource/ # list/detail + acknowledge action (tenant-scoped)
|
||||||
|
├── Jobs/
|
||||||
|
│ └── GenerateDriftFindingsJob.php # async generator (on-demand)
|
||||||
|
├── Models/
|
||||||
|
│ └── Finding.php # generic finding model
|
||||||
|
└── Services/
|
||||||
|
└── Drift/
|
||||||
|
├── DriftFindingGenerator.php # computes deterministic findings for baseline/current
|
||||||
|
├── DriftHasher.php # baseline_hash/current_hash helpers
|
||||||
|
└── DriftScopeKey.php # scope_key is InventorySyncRun.selection_hash (single canonical definition)
|
||||||
|
|
||||||
|
database/migrations/
|
||||||
|
└── 2026_.._.._create_findings_table.php
|
||||||
|
|
||||||
|
tests/Feature/Drift/
|
||||||
|
├── DriftGenerationDeterminismTest.php
|
||||||
|
├── DriftTenantIsolationTest.php
|
||||||
|
└── DriftAcknowledgeTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel monolith using Filament pages/resources and queued jobs.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| (none) | | |
|
||||||
|
|
||||||
|
## Phase 0 Output (Research)
|
||||||
|
|
||||||
|
Completed in `specs/044-drift-mvp/research.md`.
|
||||||
|
|
||||||
|
## Phase 1 Output (Design)
|
||||||
|
|
||||||
|
Completed in:
|
||||||
|
|
||||||
|
- `specs/044-drift-mvp/data-model.md`
|
||||||
|
- `specs/044-drift-mvp/contracts/`
|
||||||
|
- `specs/044-drift-mvp/quickstart.md`
|
||||||
|
|
||||||
|
## Phase 2 Planning Notes
|
||||||
|
|
||||||
|
Next step is expanding `specs/044-drift-mvp/tasks.md` (via `/speckit.tasks`) with phased, test-first implementation tasks.
|
||||||
|
|||||||
32
specs/044-drift-mvp/quickstart.md
Normal file
32
specs/044-drift-mvp/quickstart.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Quickstart: Drift MVP (044)
|
||||||
|
|
||||||
|
## Run locally (Sail)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./vendor/bin/sail up -d
|
||||||
|
./vendor/bin/sail artisan queue:work --tries=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prepare data
|
||||||
|
|
||||||
|
1. Open the admin panel and select a tenant context.
|
||||||
|
2. Navigate to Inventory and run an Inventory Sync **twice** with the same selection (same `selection_hash`).
|
||||||
|
|
||||||
|
## Use Drift
|
||||||
|
|
||||||
|
1. Navigate to the new Drift area.
|
||||||
|
2. On first open, Drift will queue background generation and record status in a persisted run record.
|
||||||
|
3. Generation produces findings for:
|
||||||
|
- baseline = previous successful run for the same `scope_key`
|
||||||
|
- current = latest successful run for the same `scope_key`
|
||||||
|
4. Refresh the page once generation finishes.
|
||||||
|
|
||||||
|
## Triage
|
||||||
|
|
||||||
|
- Acknowledge a finding; it moves out of the default “new” view but remains visible/auditable.
|
||||||
|
- Use the status filter to include acknowledged findings.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- UI must remain DB-only for label resolution (no render-time Graph calls).
|
||||||
|
- Findings store minimal, sanitized evidence only.
|
||||||
72
specs/044-drift-mvp/research.md
Normal file
72
specs/044-drift-mvp/research.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Phase 0 Output: Research (044)
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1) `scope_key` reuse
|
||||||
|
|
||||||
|
- Decision: Use the existing Inventory selection hash as `scope_key`.
|
||||||
|
- Concretely: `scope_key = InventorySyncRun.selection_hash`.
|
||||||
|
- Rationale:
|
||||||
|
- Inventory already normalizes + hashes selection payload deterministically (via `InventorySelectionHasher`).
|
||||||
|
- It is already used for concurrency/deduping inventory runs, so it’s the right stable scope identifier.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Compute a second hash (duplicate of selection_hash) → adds drift without benefit.
|
||||||
|
- Store the raw selection payload as the primary key → not stable without strict normalization.
|
||||||
|
|
||||||
|
### 2) Baseline selection (MVP)
|
||||||
|
|
||||||
|
- Decision: Baseline run = previous successful inventory sync run for the same `scope_key`; comparison run = latest successful inventory sync run for the same `scope_key`.
|
||||||
|
- Rationale:
|
||||||
|
- Matches “run at least twice” scenario.
|
||||||
|
- Deterministic and explainable.
|
||||||
|
- Alternatives considered:
|
||||||
|
- User-pinned baselines → valuable, but deferred (design must allow later via `scope_key`).
|
||||||
|
|
||||||
|
### 3) Persisted generic Findings
|
||||||
|
|
||||||
|
- Decision: Persist Findings in a generic `findings` table.
|
||||||
|
- Rationale:
|
||||||
|
- Enables stable triage (`acknowledged`) without recomputation drift.
|
||||||
|
- Reusable pipeline for Drift now, Audit/Compare later.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Compute-on-demand and store only acknowledgements by fingerprint → harder operationally and can surprise users when diff rules evolve.
|
||||||
|
|
||||||
|
### 4) Generation trigger (MVP)
|
||||||
|
|
||||||
|
- Decision: On opening Drift, if findings for (tenant, `scope_key`, baseline_run_id, current_run_id) do not exist, dispatch an async job to generate them.
|
||||||
|
- Rationale:
|
||||||
|
- Avoids long request times.
|
||||||
|
- Avoids scheduled complexity in MVP.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Generate after every inventory run → may be expensive; can be added later.
|
||||||
|
- Nightly schedule → hides immediacy and complicates operations.
|
||||||
|
|
||||||
|
### 5) Fingerprint and state hashing
|
||||||
|
|
||||||
|
- Decision: Use a deterministic fingerprint that changes when the underlying state changes.
|
||||||
|
- Fingerprint = `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)`.
|
||||||
|
- baseline_hash/current_hash are computed over normalized, sanitized comparison data (exclude volatile fields like timestamps).
|
||||||
|
- Rationale:
|
||||||
|
- Stable identity for triage and audit.
|
||||||
|
- Supports future generators (audit/compare) using same semantics.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Fingerprint without baseline/current hash → cannot distinguish changed vs unchanged findings.
|
||||||
|
|
||||||
|
### 6) Evidence minimization
|
||||||
|
|
||||||
|
- Decision: Store small, sanitized `evidence_jsonb` with an allowlist shape; no raw payload dumps.
|
||||||
|
- Rationale:
|
||||||
|
- Aligns with data minimization + safe logging.
|
||||||
|
- Avoids storing secrets/tokens.
|
||||||
|
|
||||||
|
### 7) Name resolution and Graph safety
|
||||||
|
|
||||||
|
- Decision: UI resolves human-readable labels using DB-backed Inventory + Foundations (047) + Groups Cache (051). No render-time Graph calls.
|
||||||
|
- Rationale:
|
||||||
|
- Works offline / when tokens are broken.
|
||||||
|
- Keeps UI safe and predictable.
|
||||||
|
|
||||||
|
## Notes / Follow-ups for Phase 1
|
||||||
|
|
||||||
|
- Define the `findings` table indexes carefully for tenant-scoped filtering (status, type, scope_key, run_ids).
|
||||||
|
- Consider using existing observable run patterns (BulkOperationRun + AuditLogger) for drift generation jobs.
|
||||||
@ -1,55 +1,81 @@
|
|||||||
# Feature Specification: Drift MVP
|
# Feature Specification: Drift MVP (044)
|
||||||
|
|
||||||
**Feature Branch**: `feat/044-drift-mvp`
|
|
||||||
**Created**: 2026-01-07
|
|
||||||
**Status**: Draft
|
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Detect and report drift between expected and observed states using inventory and run metadata.
|
Help admins quickly spot and triage configuration “drift”: what changed between two inventory snapshots.
|
||||||
|
|
||||||
This MVP focuses on reporting and triage, not automatic remediation.
|
This MVP is about visibility and acknowledgement (triage), not automatic fixes.
|
||||||
|
|
||||||
## User Scenarios & Testing
|
## User Scenarios
|
||||||
|
|
||||||
### Scenario 1: View drift summary
|
### Scenario 1: View drift summary
|
||||||
- Given inventory sync has run at least twice
|
|
||||||
- When the admin opens Drift
|
- Given the system has at least two successful inventory snapshots for the same selection/scope
|
||||||
- Then they see a summary of changes since the last baseline
|
- When an admin opens Drift
|
||||||
|
- Then they see a summary of what was added, removed, or changed since the previous snapshot
|
||||||
|
|
||||||
### Scenario 2: Drill into a drift finding
|
### Scenario 2: Drill into a drift finding
|
||||||
|
|
||||||
|
- Given drift findings exist for a comparison
|
||||||
|
- When an admin opens a specific finding
|
||||||
|
- Then they can see what changed and which two snapshots were compared
|
||||||
|
|
||||||
|
### Scenario 3: Acknowledge / triage
|
||||||
|
|
||||||
- Given a drift finding exists
|
- Given a drift finding exists
|
||||||
- When the admin opens the finding
|
- When an admin acknowledges it
|
||||||
- Then they see what changed, when, and which run observed it
|
- Then it no longer appears in “new” views, but remains available for audit/history
|
||||||
|
|
||||||
### Scenario 3: Acknowledge/triage
|
## Rules
|
||||||
- Given a drift finding exists
|
|
||||||
- When the admin marks it acknowledged
|
|
||||||
- Then it is hidden from “new” lists but remains auditable
|
|
||||||
|
|
||||||
## Functional Requirements
|
### Coverage (MVP)
|
||||||
|
|
||||||
- FR1: Define a baseline concept (e.g., last completed run for a selection scope).
|
- Drift findings cover **policies, their assignments, and scope tags** for the selected scope.
|
||||||
- FR2: Produce drift findings for adds/removals/metadata changes based on inventory/run state.
|
|
||||||
- FR3: Provide drift UI with summary and details.
|
|
||||||
- FR4: Allow acknowledgement/triage states.
|
|
||||||
|
|
||||||
## Non-Functional Requirements
|
### Baseline and comparison selection
|
||||||
|
|
||||||
- NFR1: Drift generation must be deterministic for the same baseline and scope.
|
- Drift always compares two successful inventory snapshots for the same selection/scope.
|
||||||
- NFR2: Drift must remain tenant-scoped and safe to display.
|
- The “current” snapshot is the latest successful snapshot for that scope.
|
||||||
|
- The “baseline” snapshot is the previous successful snapshot for that scope.
|
||||||
|
|
||||||
## Success Criteria
|
### Change types
|
||||||
|
|
||||||
- SC1: Admins can identify drift across supported types in under 3 minutes.
|
Each drift finding must be categorized as one of:
|
||||||
- SC2: Drift results are consistent across repeated generation for the same baseline.
|
|
||||||
|
|
||||||
## Out of Scope
|
- **added**: the item exists in current but not in baseline
|
||||||
|
- **removed**: the item exists in baseline but not in current
|
||||||
|
- **modified**: the item exists in both but differs (including assignment target and/or intent changes)
|
||||||
|
|
||||||
- Automatic revert/promotion.
|
### Acknowledgement
|
||||||
|
|
||||||
## Related Specs
|
- Acknowledgement is **per comparison** (baseline + current within a scope).
|
||||||
|
- Acknowledgement does **not** carry forward to later comparisons.
|
||||||
|
|
||||||
- Program: `specs/039-inventory-program/spec.md`
|
### UI states
|
||||||
- Core: `specs/040-inventory-core/spec.md`
|
|
||||||
- Compare: `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
- **blocked**: If fewer than two successful snapshots exist for the same scope, Drift shows a clear blocked state and does not attempt generation.
|
||||||
|
- **error**: If drift generation fails for a comparison, Drift shows a clear error state with safe information and reference identifiers to the recorded run.
|
||||||
|
|
||||||
|
### Default views
|
||||||
|
|
||||||
|
- Default Drift summary and default finding lists show **new** findings only.
|
||||||
|
- Acknowledged findings are accessible via an explicit filter.
|
||||||
|
|
||||||
|
### Run tracking (status, errors, idempotency)
|
||||||
|
|
||||||
|
- Drift generation status and errors must be recorded in a **persisted run record** so that progress/failure survives refresh and can be inspected later.
|
||||||
|
- Re-opening Drift for the same comparison must be idempotent (it should not create duplicate work for the same comparison).
|
||||||
|
|
||||||
|
### Determinism and stable identity
|
||||||
|
|
||||||
|
- For the same scope + baseline + current, Drift must produce the same set of findings.
|
||||||
|
- Each finding must have a stable identifier (“fingerprint”) so triage actions can reliably reference the same drift item within a comparison.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- With two successful snapshots for the same scope, Drift shows a summary of **added/removed/modified** items for that comparison.
|
||||||
|
- With fewer than two successful snapshots for the same scope, Drift shows **blocked** and does not start generation.
|
||||||
|
- If generation fails, Drift shows **error** and provides reference identifiers to the persisted run record.
|
||||||
|
- Default views exclude acknowledged findings, and acknowledged findings remain available via filter.
|
||||||
|
- Acknowledging a finding records who/when acknowledged and hides it from “new” views.
|
||||||
|
- Re-running generation for the same comparison does not create duplicate work and produces consistent results.
|
||||||
|
|||||||
@ -1,7 +1,185 @@
|
|||||||
# Tasks: Drift MVP
|
---
|
||||||
|
|
||||||
- [ ] T001 Define baseline and scope rules
|
description: "Task list for feature 044 drift MVP"
|
||||||
- [ ] T002 Drift finding generation (deterministic)
|
|
||||||
- [ ] T003 Drift summary + detail UI
|
---
|
||||||
- [ ] T004 Acknowledge/triage state
|
|
||||||
- [ ] T005 Tests for determinism and tenant scoping
|
# Tasks: Drift MVP (044)
|
||||||
|
|
||||||
|
**Input**: Design documents from `specs/044-drift-mvp/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required), plus `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: REQUIRED (Pest) - feature introduces runtime behavior + new persistence.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story (Scenario 1/2/3 in spec).
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Project wiring for Drift MVP.
|
||||||
|
|
||||||
|
- [x] T001 Create/update constitution gate checklist in `specs/044-drift-mvp/checklists/requirements.md`
|
||||||
|
- [x] T002 Confirm spec/plan artifacts are current in `specs/044-drift-mvp/{plan.md,spec.md,research.md,data-model.md,quickstart.md,contracts/admin-findings.openapi.yaml}`
|
||||||
|
- [x] T003 Add Drift landing page shell in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
- [x] T004 [P] Add Finding resource shells in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Persistence + authorization + deterministic IDs that all stories depend on.
|
||||||
|
|
||||||
|
**Checkpoint**: DB schema exists, tenant scoping enforced, and tests can create Finding rows.
|
||||||
|
|
||||||
|
- [x] T005 Create `findings` migration in `database/migrations/*_create_findings_table.php` with Finding fields aligned to `specs/044-drift-mvp/spec.md`:
|
||||||
|
(tenant_id, finding_type, scope_key, baseline_run_id, current_run_id, subject_type, subject_external_id, severity, status, fingerprint unique, evidence_jsonb, acknowledged_at, acknowledged_by_user_id)
|
||||||
|
- [x] T006 Create `Finding` model in `app/Models/Finding.php` (casts for `evidence_jsonb`, enums/constants for `finding_type`/`severity`/`status`, `acknowledged_at` handling)
|
||||||
|
- [x] T007 [P] Add `FindingFactory` in `database/factories/FindingFactory.php`
|
||||||
|
- [x] T008 Ensure `InventorySyncRunFactory` exists (or add it) in `database/factories/InventorySyncRunFactory.php`
|
||||||
|
- [x] T009 Add authorization policy in `app/Policies/FindingPolicy.php` and wire it in `app/Providers/AuthServiceProvider.php` (or project equivalent)
|
||||||
|
- [x] T010 Add Drift permissions in `config/intune_permissions.php` (view + acknowledge) and wire them into Filament navigation/actions
|
||||||
|
- [x] T011 Enforce tenant scoping for Finding queries in `app/Models/Finding.php` and/or `app/Filament/Resources/FindingResource.php`
|
||||||
|
- [x] T012 Implement fingerprint helper in `app/Services/Drift/DriftHasher.php` (sha256 per spec)
|
||||||
|
- [x] T013 Pin `scope_key = InventorySyncRun.selection_hash` in `app/Services/Drift/DriftScopeKey.php` (single canonical definition)
|
||||||
|
- [x] T014 Implement evidence allowlist builder in `app/Services/Drift/DriftEvidence.php` (new file)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - View drift summary (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: Opening Drift generates (async) and displays a summary of new drift findings for the latest scope.
|
||||||
|
|
||||||
|
**Independent Test**: With 2 successful inventory runs for the same selection hash, opening Drift dispatches generation if missing and then shows summary counts.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T015 [P] [US1] Baseline selection tests in `tests/Feature/Drift/DriftBaselineSelectionTest.php`
|
||||||
|
- [x] T016 [P] [US1] Generation dispatch tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
|
||||||
|
- [x] T017 [P] [US1] Tenant isolation tests in `tests/Feature/Drift/DriftTenantIsolationTest.php`
|
||||||
|
- [x] T018 [P] [US1] Assignment drift detection test (targets + intent changes per FR2b) in `tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T019 [US1] Implement run selection service in `app/Services/Drift/DriftRunSelector.php`
|
||||||
|
- [x] T021 [US1] Implement generator service in `app/Services/Drift/DriftFindingGenerator.php` (idempotent)
|
||||||
|
- [x] T020 [US1] Implement generator job in `app/Jobs/GenerateDriftFindingsJob.php` (dedupe/lock by tenant+scope+baseline+current)
|
||||||
|
- [x] T022 [US1] Implement landing behavior (dispatch + status UI) in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
- [x] T023 [US1] Implement summary queries/widgets in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Drill into a drift finding (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Admin can view a finding and see sanitized evidence + run references (DB-only label resolution).
|
||||||
|
|
||||||
|
**Independent Test**: A persisted finding renders details without Graph calls.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T024 [P] [US2] Finding detail test in `tests/Feature/Drift/DriftFindingDetailTest.php`
|
||||||
|
- [x] T025 [P] [US2] Evidence minimization test in `tests/Feature/Drift/DriftEvidenceMinimizationTest.php`
|
||||||
|
- [x] T041 [P] [US2] Finding detail shows normalized settings diff (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php`
|
||||||
|
- [x] T042 [P] [US2] Finding detail shows assignments diff + cached group labels (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T026 [US2] Implement list UI in `app/Filament/Resources/FindingResource.php` (filters: status, scope_key, run)
|
||||||
|
- [x] T027 [US2] Implement detail UI in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||||
|
- [x] T028 [US2] Implement DB-only name resolution in `app/Filament/Resources/FindingResource.php` (inventory/foundations caches)
|
||||||
|
- [x] T043 [US2] Add real diffs to Finding detail (settings + assignments) in `app/Filament/Resources/FindingResource.php`, `app/Services/Drift/*`, and `resources/views/filament/infolists/entries/assignments-diff.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Acknowledge/triage (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Admin can acknowledge findings; new lists hide acknowledged but records remain auditable.
|
||||||
|
|
||||||
|
**Independent Test**: Acknowledging sets `acknowledged_at` + `acknowledged_by_user_id` and flips status.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T029 [P] [US3] Acknowledge action test in `tests/Feature/Drift/DriftAcknowledgeTest.php`
|
||||||
|
- [x] T030 [P] [US3] Acknowledge authorization test in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T031 [US3] Add acknowledge actions in `app/Filament/Resources/FindingResource.php`
|
||||||
|
- [x] T032 [US3] Implement `acknowledge()` domain method in `app/Models/Finding.php`
|
||||||
|
- [x] T033 [US3] Ensure Drift summary excludes acknowledged by default in `app/Filament/Pages/DriftLanding.php`
|
||||||
|
|
||||||
|
### Bulk triage (post-MVP UX)
|
||||||
|
|
||||||
|
**Goal**: Admin can acknowledge many findings safely and quickly.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T048 [P] [US3] Bulk acknowledge selected test in `tests/Feature/Drift/DriftBulkAcknowledgeTest.php`
|
||||||
|
- [x] T049 [P] [US3] Acknowledge all matching current filters test in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php`
|
||||||
|
- [x] T050 [P] [US3] Acknowledge all matching requires type-to-confirm when >100 in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php`
|
||||||
|
- [x] T051 [P] [US3] Bulk acknowledge authorization test in `tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T052 [US3] Add bulk triage actions to Findings list in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [x] T034 Add DB indexes in `database/migrations/*_create_findings_table.php` (tenant_id+status, tenant_id+scope_key, tenant_id+baseline_run_id, tenant_id+current_run_id)
|
||||||
|
- [x] T035 [P] Add determinism test in `tests/Feature/Drift/DriftGenerationDeterminismTest.php` (same baseline/current => same fingerprints)
|
||||||
|
- [x] T036 Add job observability logs in `app/Jobs/GenerateDriftFindingsJob.php` (no secrets)
|
||||||
|
- [x] T037 Add idempotency/upsert strategy in `app/Services/Drift/DriftFindingGenerator.php`
|
||||||
|
- [x] T038 Ensure volatile fields excluded from hashing in `app/Services/Drift/DriftHasher.php` and cover in `tests/Feature/Drift/DriftHasherTest.php`
|
||||||
|
- [x] T039 Validate and update `specs/044-drift-mvp/quickstart.md` after implementation
|
||||||
|
- [x] T040 Fix drift evidence summary shape in `app/Services/Drift/DriftFindingGenerator.php` and cover in `tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php` (snapshot_hash vs assignments_hash)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Scope Tags Drift (Post-MVP Fix)
|
||||||
|
|
||||||
|
**Goal**: Detect and display drift caused by `roleScopeTagIds` changes on a policy version.
|
||||||
|
|
||||||
|
### Tests (write first)
|
||||||
|
|
||||||
|
- [x] T044 [P] [US1] Scope-tag drift detection test in `tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php`
|
||||||
|
- [x] T045 [P] [US2] Finding detail shows scope-tags diff (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php`
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T046 [US1] Implement scope-tag drift detection in `app/Services/Drift/DriftFindingGenerator.php` (deterministic hashing; kind=`policy_scope_tags`)
|
||||||
|
- [x] T047 [US2] Implement scope-tags diff builder + UI in `app/Services/Drift/DriftFindingDiffBuilder.php`, `app/Filament/Resources/FindingResource.php`, and `resources/views/filament/infolists/entries/scope-tags-diff.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
- Setup (Phase 1) -> Foundational (Phase 2) -> US1 -> US2 -> US3 -> Polish
|
||||||
|
|
||||||
|
### Parallel execution examples
|
||||||
|
|
||||||
|
- Foundational: T007, T008, T012, T013, T014
|
||||||
|
- US1 tests: T015, T016, T017, T018
|
||||||
|
- US2 tests: T024, T025
|
||||||
|
- US3 tests: T029, T030
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP scope
|
||||||
|
|
||||||
|
- MVP = Phase 1 + Phase 2 + US1.
|
||||||
|
|
||||||
|
### Format validation
|
||||||
|
|
||||||
|
- All tasks use `- [ ] T###` format
|
||||||
|
- Story tasks include `[US1]`/`[US2]`/`[US3]`
|
||||||
|
- All tasks include file paths
|
||||||
|
|||||||
31
tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php
Normal file
31
tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('readonly users cannot acknowledge findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableAction('acknowledge', $finding);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_NEW);
|
||||||
|
});
|
||||||
28
tests/Feature/Drift/DriftAcknowledgeTest.php
Normal file
28
tests/Feature/Drift/DriftAcknowledgeTest.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('a finding can be acknowledged via table action', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'acknowledged_at' => null,
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableAction('acknowledge', $finding);
|
||||||
|
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
expect($finding->acknowledged_at)->not->toBeNull();
|
||||||
|
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
74
tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php
Normal file
74
tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it creates a drift finding when policy assignment targets change', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-assignments');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineAssignments = [
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'group-a',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$currentAssignments = [
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => 'group-b',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $baselineAssignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $currentAssignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(1);
|
||||||
|
|
||||||
|
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
|
||||||
|
expect($finding)->not->toBeNull();
|
||||||
|
expect($finding->subject_type)->toBe('assignment');
|
||||||
|
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||||
|
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||||
|
});
|
||||||
60
tests/Feature/Drift/DriftBaselineSelectionTest.php
Normal file
60
tests/Feature/Drift/DriftBaselineSelectionTest.php
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Services\Drift\DriftRunSelector;
|
||||||
|
|
||||||
|
test('it selects the previous and latest successful runs for the same scope', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->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();
|
||||||
|
});
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('acknowledge all matching requires confirmation when acknowledging more than 100 findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(101)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->mountAction('acknowledge_all_matching')
|
||||||
|
->callMountedAction();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->mountAction('acknowledge_all_matching')
|
||||||
|
->setActionData(['confirmation' => 'ACKNOWLEDGE'])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED));
|
||||||
|
});
|
||||||
51
tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php
Normal file
51
tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('acknowledge all matching respects scope key filter', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeA = 'scope-a';
|
||||||
|
$scopeB = 'scope-b';
|
||||||
|
|
||||||
|
$matching = Finding::factory()
|
||||||
|
->count(2)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'scope_key' => $scopeA,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$nonMatching = Finding::factory()
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'scope_key' => $scopeB,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->set('tableFilters', [
|
||||||
|
'status' => ['value' => Finding::STATUS_NEW],
|
||||||
|
'finding_type' => ['value' => Finding::FINDING_TYPE_DRIFT],
|
||||||
|
'scope_key' => ['scope_key' => $scopeA],
|
||||||
|
])
|
||||||
|
->callAction('acknowledge_all_matching');
|
||||||
|
|
||||||
|
$matching->each(function (Finding $finding) use ($user): void {
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
$nonMatching->refresh();
|
||||||
|
expect($nonMatching->status)->toBe(Finding::STATUS_NEW);
|
||||||
|
expect($nonMatching->acknowledged_at)->toBeNull();
|
||||||
|
});
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('readonly users cannot bulk acknowledge selected findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(2)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableBulkAction('acknowledge_selected', $findings);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readonly users cannot acknowledge all matching findings', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(2)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$thrown = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callAction('acknowledge_all_matching');
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$thrown = $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect($thrown)->not->toBeNull();
|
||||||
|
|
||||||
|
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||||
|
});
|
||||||
34
tests/Feature/Drift/DriftBulkAcknowledgeTest.php
Normal file
34
tests/Feature/Drift/DriftBulkAcknowledgeTest.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('findings can be acknowledged via table bulk action', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$findings = Finding::factory()
|
||||||
|
->count(3)
|
||||||
|
->for($tenant)
|
||||||
|
->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'status' => Finding::STATUS_NEW,
|
||||||
|
'acknowledged_at' => null,
|
||||||
|
'acknowledged_by_user_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListFindings::class)
|
||||||
|
->callTableBulkAction('acknowledge_selected', $findings)
|
||||||
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
|
$findings->each(function (Finding $finding) use ($user): void {
|
||||||
|
$finding->refresh();
|
||||||
|
|
||||||
|
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||||
|
expect($finding->acknowledged_at)->not->toBeNull();
|
||||||
|
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('opening Drift does not re-dispatch when the last run completed with zero findings', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-zero-findings');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
BulkOperationRun::factory()->for($tenant)->for($user)->create([
|
||||||
|
'resource' => 'drift',
|
||||||
|
'action' => 'generate',
|
||||||
|
'status' => 'completed',
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
|
'item_ids' => [$scopeKey],
|
||||||
|
'total_items' => 1,
|
||||||
|
'processed_items' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(DriftLanding::class)
|
||||||
|
->assertSet('state', 'ready')
|
||||||
|
->assertSet('scopeKey', $scopeKey);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
|
||||||
|
expect(BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(GenerateDriftFindingsJob::class);
|
||||||
|
});
|
||||||
24
tests/Feature/Drift/DriftEvidenceMinimizationTest.php
Normal file
24
tests/Feature/Drift/DriftEvidenceMinimizationTest.php
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Drift\DriftEvidence;
|
||||||
|
|
||||||
|
test('drift evidence sanitizer keeps only allowlisted keys', function () {
|
||||||
|
$payload = [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => ['changed_fields' => ['assignments_hash']],
|
||||||
|
'baseline' => ['hash' => 'a'],
|
||||||
|
'current' => ['hash' => 'b'],
|
||||||
|
'diff' => ['a' => 'b'],
|
||||||
|
'notes' => 'ok',
|
||||||
|
'access_token' => 'should-not-leak',
|
||||||
|
'client_secret' => 'should-not-leak',
|
||||||
|
'raw_payload' => ['big' => 'blob'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$safe = app(DriftEvidence::class)->sanitize($payload);
|
||||||
|
|
||||||
|
expect($safe)->toHaveKeys(['change_type', 'summary', 'baseline', 'current', 'diff', 'notes']);
|
||||||
|
expect($safe)->not->toHaveKey('access_token');
|
||||||
|
expect($safe)->not->toHaveKey('client_secret');
|
||||||
|
expect($safe)->not->toHaveKey('raw_payload');
|
||||||
|
});
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\EntraGroup;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
|
|
||||||
|
test('finding detail shows an assignments diff with DB-only group label resolution', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-assignments-diff'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'external_id' => 'policy-456',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$group1 = '76b787af-cae9-4a8e-89e9-b8cc67f81779';
|
||||||
|
$group2 = '6b0bc3d7-91f3-4e4b-8181-8236d908d2dd';
|
||||||
|
$group3 = 'cbd8d685-0d95-4de0-8fce-140a5cad8ddc';
|
||||||
|
|
||||||
|
EntraGroup::factory()->for($tenant)->create([
|
||||||
|
'entra_id' => strtolower($group1),
|
||||||
|
'display_name' => 'Group One',
|
||||||
|
]);
|
||||||
|
|
||||||
|
EntraGroup::factory()->for($tenant)->create([
|
||||||
|
'entra_id' => strtolower($group3),
|
||||||
|
'display_name' => 'Group Three',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => $group1,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||||
|
'groupId' => $group2,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||||
|
'assignments' => [
|
||||||
|
[
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => $group1,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => '62fb77d0-8f85-4ba0-a1c7-fd71d418521d',
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'intent' => 'apply',
|
||||||
|
'target' => [
|
||||||
|
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||||
|
'groupId' => $group3,
|
||||||
|
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||||
|
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'assignment',
|
||||||
|
'subject_external_id' => $policy->external_id,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_assignments',
|
||||||
|
'changed_fields' => ['assignments_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
|
'assignments_hash' => 'baseline-assignments-hash',
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'assignments_hash' => 'current-assignments-hash',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy 456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$expectedGroup1 = EntraGroupLabelResolver::formatLabel('Group One', $group1);
|
||||||
|
$expectedGroup2 = EntraGroupLabelResolver::formatLabel(null, $group2);
|
||||||
|
$expectedGroup3 = EntraGroupLabelResolver::formatLabel('Group Three', $group3);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Assignments diff')
|
||||||
|
->assertSee('1 added')
|
||||||
|
->assertSee('1 removed')
|
||||||
|
->assertSee('1 changed')
|
||||||
|
->assertSee($expectedGroup1)
|
||||||
|
->assertSee($expectedGroup2)
|
||||||
|
->assertSee($expectedGroup3)
|
||||||
|
->assertSee('include')
|
||||||
|
->assertSee('none');
|
||||||
|
});
|
||||||
100
tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php
Normal file
100
tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
|
||||||
|
test('finding detail shows a scope tags diff without Graph calls', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-scope-tags-diff'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'external_id' => 'policy-scope-tags-1',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 17,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0'],
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 18,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0', 'a1b2c3'],
|
||||||
|
'names' => ['Default', 'Verbund-1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'scope_tag',
|
||||||
|
'subject_external_id' => $policy->external_id,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'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' => 'baseline-scope-tags-hash',
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'scope_tags_hash' => 'current-scope-tags-hash',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy Scope Tags',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Scope tags diff')
|
||||||
|
->assertSee('1 added')
|
||||||
|
->assertSee('0 removed')
|
||||||
|
->assertSee('Verbund-1')
|
||||||
|
->assertSee('Default');
|
||||||
|
});
|
||||||
100
tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php
Normal file
100
tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
|
||||||
|
test('finding detail shows a normalized settings diff for policy_snapshot evidence', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-settings-diff'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'external_id' => 'policy-123',
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'Old description',
|
||||||
|
'customSettingFoo' => 'Old value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||||
|
'policy_id' => $policy->getKey(),
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'New description',
|
||||||
|
'customSettingFoo' => 'New value',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'policy',
|
||||||
|
'subject_external_id' => $policy->external_id,
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => [
|
||||||
|
'kind' => 'policy_snapshot',
|
||||||
|
'changed_fields' => ['snapshot_hash'],
|
||||||
|
],
|
||||||
|
'baseline' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $baselineVersion->getKey(),
|
||||||
|
'snapshot_hash' => 'baseline-hash',
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'policy_id' => $policy->external_id,
|
||||||
|
'policy_version_id' => $currentVersion->getKey(),
|
||||||
|
'snapshot_hash' => 'current-hash',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy 123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Normalized diff')
|
||||||
|
->assertSee('1 changed')
|
||||||
|
->assertSee('Custom Setting Foo')
|
||||||
|
->assertSee('From')
|
||||||
|
->assertSee('To')
|
||||||
|
->assertSee('Old value')
|
||||||
|
->assertSee('New value');
|
||||||
|
});
|
||||||
48
tests/Feature/Drift/DriftFindingDetailTest.php
Normal file
48
tests/Feature/Drift/DriftFindingDetailTest.php
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\FindingResource;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
|
||||||
|
test('finding detail renders without Graph calls', function () {
|
||||||
|
bindFailHardGraphClient();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => hash('sha256', 'scope-detail'),
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $baseline->selection_hash,
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$finding = Finding::factory()->for($tenant)->create([
|
||||||
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
|
'scope_key' => (string) $current->selection_hash,
|
||||||
|
'baseline_run_id' => $baseline->getKey(),
|
||||||
|
'current_run_id' => $current->getKey(),
|
||||||
|
'subject_type' => 'deviceConfiguration',
|
||||||
|
'subject_external_id' => 'policy-123',
|
||||||
|
'evidence_jsonb' => [
|
||||||
|
'change_type' => 'modified',
|
||||||
|
'summary' => ['changed_fields' => ['assignments_hash']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventoryItem = InventoryItem::factory()->for($tenant)->create([
|
||||||
|
'external_id' => $finding->subject_external_id,
|
||||||
|
'display_name' => 'My Policy 123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee($finding->fingerprint)
|
||||||
|
->assertSee($inventoryItem->display_name);
|
||||||
|
});
|
||||||
76
tests/Feature/Drift/DriftGenerationDeterminismTest.php
Normal file
76
tests/Feature/Drift/DriftGenerationDeterminismTest.php
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('drift generation is deterministic for the same baseline/current', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-determinism');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineAssignments = [
|
||||||
|
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-a']],
|
||||||
|
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-b']],
|
||||||
|
];
|
||||||
|
|
||||||
|
$currentAssignments = [
|
||||||
|
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-c']],
|
||||||
|
];
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $baselineAssignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $currentAssignments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
|
||||||
|
$created1 = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
$fingerprints1 = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->pluck('fingerprint')
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$created2 = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
$fingerprints2 = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->pluck('fingerprint')
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
expect($created1)->toBeGreaterThan(0);
|
||||||
|
expect($created2)->toBe(0);
|
||||||
|
expect($fingerprints2)->toBe($fingerprints1);
|
||||||
|
});
|
||||||
129
tests/Feature/Drift/DriftGenerationDispatchTest.php
Normal file
129
tests/Feature/Drift/DriftGenerationDispatchTest.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
|
use App\Models\BulkOperationRun;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Support\RunIdempotency;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('opening Drift dispatches generation when findings are missing', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->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);
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$bulkRun = BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($bulkRun)->not->toBeNull();
|
||||||
|
expect($bulkRun->resource)->toBe('drift');
|
||||||
|
expect($bulkRun->action)->toBe('generate');
|
||||||
|
expect($bulkRun->status)->toBe('pending');
|
||||||
|
|
||||||
|
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): 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
|
||||||
|
&& $job->bulkOperationRunId === (int) $bulkRun->getKey();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening Drift is idempotent while a run is pending', function () {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-idempotent');
|
||||||
|
|
||||||
|
$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);
|
||||||
|
Livewire::test(DriftLanding::class);
|
||||||
|
|
||||||
|
Queue::assertPushed(GenerateDriftFindingsJob::class, 1);
|
||||||
|
|
||||||
|
$idempotencyKey = RunIdempotency::buildKey(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
operationType: 'drift.generate',
|
||||||
|
targetId: $scopeKey,
|
||||||
|
context: [
|
||||||
|
'scope_key' => $scopeKey,
|
||||||
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
|
'current_run_id' => (int) $current->getKey(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(BulkOperationRun::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('idempotency_key', $idempotencyKey)
|
||||||
|
->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
39
tests/Feature/Drift/DriftHasherTest.php
Normal file
39
tests/Feature/Drift/DriftHasherTest.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Drift\DriftHasher;
|
||||||
|
|
||||||
|
test('normalized hashing ignores volatile timestamps', function () {
|
||||||
|
$hasher = app(DriftHasher::class);
|
||||||
|
|
||||||
|
$a = [
|
||||||
|
'id' => 'abc',
|
||||||
|
'createdDateTime' => '2020-01-01T00:00:00Z',
|
||||||
|
'lastModifiedDateTime' => '2020-01-02T00:00:00Z',
|
||||||
|
'target' => ['groupId' => 'group-a'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$b = [
|
||||||
|
'id' => 'abc',
|
||||||
|
'createdDateTime' => '2025-01-01T00:00:00Z',
|
||||||
|
'lastModifiedDateTime' => '2026-01-02T00:00:00Z',
|
||||||
|
'target' => ['groupId' => 'group-a'],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($hasher->hashNormalized($a))->toBe($hasher->hashNormalized($b));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalized hashing is order-insensitive for lists', function () {
|
||||||
|
$hasher = app(DriftHasher::class);
|
||||||
|
|
||||||
|
$listA = [
|
||||||
|
['target' => ['groupId' => 'group-a']],
|
||||||
|
['target' => ['groupId' => 'group-b']],
|
||||||
|
];
|
||||||
|
|
||||||
|
$listB = [
|
||||||
|
['target' => ['groupId' => 'group-b']],
|
||||||
|
['target' => ['groupId' => 'group-a']],
|
||||||
|
];
|
||||||
|
|
||||||
|
expect($hasher->hashNormalized($listA))->toBe($hasher->hashNormalized($listB));
|
||||||
|
});
|
||||||
33
tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php
Normal file
33
tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\DriftLanding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
test('drift landing exposes baseline/current run ids and timestamps', function () {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-landing-comparison-info');
|
||||||
|
|
||||||
|
$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)
|
||||||
|
->assertSet('scopeKey', $scopeKey)
|
||||||
|
->assertSet('baselineRunId', (int) $baseline->getKey())
|
||||||
|
->assertSet('currentRunId', (int) $current->getKey())
|
||||||
|
->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString())
|
||||||
|
->assertSet('currentFinishedAt', $current->finished_at->toDateTimeString());
|
||||||
|
});
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it creates a drift finding when policy snapshot changes', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-snapshot');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Old value'],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'New value'],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(1);
|
||||||
|
|
||||||
|
$finding = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('subject_type', 'policy')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($finding)->not->toBeNull();
|
||||||
|
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||||
|
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||||
|
expect($finding->evidence_jsonb)
|
||||||
|
->toHaveKey('summary.changed_fields')
|
||||||
|
->and($finding->evidence_jsonb['summary']['changed_fields'])->toContain('snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('baseline.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('current.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash');
|
||||||
|
});
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it does not create a snapshot drift finding when only excluded metadata changes', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-snapshot-metadata-only');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'Old description',
|
||||||
|
],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => [
|
||||||
|
'displayName' => 'My Policy',
|
||||||
|
'description' => 'New description',
|
||||||
|
],
|
||||||
|
'assignments' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(0);
|
||||||
|
expect(Finding::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
84
tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php
Normal file
84
tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it creates a drift finding when policy scope tags change', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-scope-tags');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0'],
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0', 'a1b2c3'],
|
||||||
|
'names' => ['Default', 'Verbund-1'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(1);
|
||||||
|
|
||||||
|
$finding = Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->where('subject_type', 'scope_tag')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($finding)->not->toBeNull();
|
||||||
|
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||||
|
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||||
|
expect($finding->evidence_jsonb)
|
||||||
|
->toHaveKey('summary.kind', 'policy_scope_tags')
|
||||||
|
->toHaveKey('summary.changed_fields')
|
||||||
|
->and($finding->evidence_jsonb['summary']['changed_fields'])->toContain('scope_tags_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('baseline.scope_tags_hash')
|
||||||
|
->and($finding->evidence_jsonb)->toHaveKey('current.scope_tags_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('current.snapshot_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('baseline.assignments_hash')
|
||||||
|
->and($finding->evidence_jsonb)->not->toHaveKey('current.assignments_hash');
|
||||||
|
});
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('it does not create a scope tag drift finding when baseline has legacy names-only Default and current has ids', function () {
|
||||||
|
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-policy-scope-tags-legacy-default');
|
||||||
|
|
||||||
|
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['deviceConfiguration']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policy = Policy::factory()->for($tenant)->create([
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'platform' => 'windows10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
// legacy data shape (missing ids)
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||||
|
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||||
|
'assignments' => [],
|
||||||
|
'scope_tags' => [
|
||||||
|
'ids' => ['0'],
|
||||||
|
'names' => ['Default'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||||
|
|
||||||
|
expect($created)->toBe(0);
|
||||||
|
|
||||||
|
expect(Finding::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
|
->where('scope_key', $scopeKey)
|
||||||
|
->count())->toBe(0);
|
||||||
|
});
|
||||||
80
tests/Feature/Drift/DriftTenantIsolationTest.php
Normal file
80
tests/Feature/Drift/DriftTenantIsolationTest.php
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventorySyncRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
|
|
||||||
|
test('drift generation is tenant isolated', function () {
|
||||||
|
[$userA, $tenantA] = createUserWithTenant(role: 'manager');
|
||||||
|
[$userB, $tenantB] = createUserWithTenant(role: 'manager');
|
||||||
|
|
||||||
|
$scopeKey = hash('sha256', 'scope-tenant');
|
||||||
|
|
||||||
|
$baselineA = InventorySyncRun::factory()->for($tenantA)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentA = InventorySyncRun::factory()->for($tenantA)->create([
|
||||||
|
'selection_hash' => $scopeKey,
|
||||||
|
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||||
|
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||||
|
'finished_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policyA = Policy::factory()->for($tenantA)->create([
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineAssignments = [['target' => ['groupId' => 'group-a'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||||
|
$currentAssignments = [['target' => ['groupId' => 'group-b'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policyA->policy_type,
|
||||||
|
'captured_at' => $baselineA->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $baselineAssignments,
|
||||||
|
'assignments_hash' => hash('sha256', json_encode($baselineAssignments)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenantA)->for($policyA)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policyA->policy_type,
|
||||||
|
'captured_at' => $currentA->finished_at->copy()->subMinute(),
|
||||||
|
'assignments' => $currentAssignments,
|
||||||
|
'assignments_hash' => hash('sha256', json_encode($currentAssignments)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$policyB = Policy::factory()->for($tenantB)->create([
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baselineAssignmentsB = [['target' => ['groupId' => 'group-x'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||||
|
$currentAssignmentsB = [['target' => ['groupId' => 'group-y'], '@odata.type' => '#microsoft.graph.groupAssignmentTarget']];
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policyB->policy_type,
|
||||||
|
'captured_at' => now()->subDays(2)->subMinute(),
|
||||||
|
'assignments' => $baselineAssignmentsB,
|
||||||
|
'assignments_hash' => hash('sha256', json_encode($baselineAssignmentsB)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
PolicyVersion::factory()->for($tenantB)->for($policyB)->create([
|
||||||
|
'version_number' => 2,
|
||||||
|
'policy_type' => $policyB->policy_type,
|
||||||
|
'captured_at' => now()->subDay()->subMinute(),
|
||||||
|
'assignments' => $currentAssignmentsB,
|
||||||
|
'assignments_hash' => hash('sha256', json_encode($currentAssignmentsB)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$generator = app(DriftFindingGenerator::class);
|
||||||
|
$generator->generate($tenantA, $baselineA, $currentA, $scopeKey);
|
||||||
|
|
||||||
|
expect(Finding::query()->where('tenant_id', $tenantA->getKey())->count())->toBe(1);
|
||||||
|
expect(Finding::query()->where('tenant_id', $tenantB->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user