From bc846d7c5c38cd8d21e84a3b341997cf3fe28db4 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 11 Jan 2026 23:24:12 +0000 Subject: [PATCH 1/5] 051-entra-group-directory-cache (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Adds a tenant-scoped Entra Groups “Directory Cache” to enable DB-only group name resolution across the app (no render-time Graph calls), plus sync runs + observability. What’s included • Entra Groups cache • New entra_groups storage (tenant-scoped) for group metadata (no memberships). • Retention semantics: groups become stale / retained per spec (no hard delete on first miss). • Group Sync Runs • New “Group Sync Runs” UI (list + detail) with tenant isolation (403 on cross-tenant access). • Manual “Sync Groups” action: creates/reuses a run, dispatches job, DB notification with “View run” link. • Scheduled dispatcher command wired in console.php. • DB-only label resolution (US3) • Shared EntraGroupLabelResolver with safe fallback Unresolved (…last8) and UUID guarding. • Refactors to prefer cached names (no typeahead / no live Graph) in: • Tenant RBAC group selects • Policy version assignments widget • Restore results + restore wizard group mapping labels Safety / Guardrails • No render-time Graph calls: fail-hard guard test verifies UI paths don’t call GraphClientInterface during page render. • Tenant isolation & authorization: policies + scoped queries enforced (cross-tenant access returns 403, not 404). • Data minimization: only group metadata is cached (no membership/owners). Tests / Verification • Added/updated tests under tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups: • Start sync → run record + job dispatch + upserts • Retention purge semantics • Scheduled dispatch wiring • Render-time Graph guard • UI/resource access isolation • Ran: • ./vendor/bin/pint --dirty • ./vendor/bin/sail artisan test tests/Feature/DirectoryGroups • ./vendor/bin/sail artisan test tests/Unit/DirectoryGroups Notes / Follow-ups • UI polish remains (picker/lookup UX, consistent progress widget/toasts across modules, navigation grouping). • pr-gate checklist still has non-blocking open items (mostly UX/ops polish); requirements gate is green. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/57 --- ...TenantpilotDispatchDirectoryGroupsSync.php | 116 ++++++++ app/Filament/Resources/EntraGroupResource.php | 211 ++++++++++++++ .../Pages/ListEntraGroups.php | 119 ++++++++ .../Pages/ViewEntraGroup.php | 11 + .../Resources/EntraGroupSyncRunResource.php | 143 ++++++++++ .../Pages/ListEntraGroupSyncRuns.php | 112 ++++++++ .../Pages/ViewEntraGroupSyncRun.php | 11 + app/Filament/Resources/RestoreRunResource.php | 97 ++++++- .../Pages/CreateRestoreRun.php | 20 ++ app/Filament/Resources/TenantResource.php | 130 ++------- app/Jobs/EntraGroupSyncJob.php | 139 ++++++++++ app/Livewire/EntraGroupCachePickerTable.php | 203 ++++++++++++++ .../PolicyVersionAssignmentsWidget.php | 68 +++++ app/Models/EntraGroup.php | 26 ++ app/Models/EntraGroupSyncRun.php | 40 +++ app/Models/Tenant.php | 10 + .../RunStatusChangedNotification.php | 30 +- app/Policies/EntraGroupPolicy.php | 39 +++ app/Policies/EntraGroupSyncRunPolicy.php | 39 +++ app/Providers/AppServiceProvider.php | 2 + app/Services/BulkOperationService.php | 14 +- .../Directory/EntraGroupLabelResolver.php | 97 +++++++ .../Directory/EntraGroupSelection.php | 20 ++ .../Directory/EntraGroupSyncService.php | 262 ++++++++++++++++++ app/Services/Graph/GraphContractRegistry.php | 12 + config/directory_groups.php | 25 ++ config/graph_contracts.php | 5 + config/intune_permissions.php | 2 +- database/factories/EntraGroupFactory.php | 28 ++ .../factories/EntraGroupSyncRunFactory.php | 45 +++ ...01_11_120003_create_entra_groups_table.php | 36 +++ ...004_create_entra_group_sync_runs_table.php | 51 ++++ .../entries/restore-results.blade.php | 27 +- .../modals/entra-group-cache-picker.blade.php | 3 + .../bulk-operation-progress.blade.php | 8 +- .../entra-group-cache-picker-table.blade.php | 3 + ...olicy-version-assignments-widget.blade.php | 17 +- routes/console.php | 1 + .../checklists/pr-gate.md | 79 ++++++ .../checklists/requirements.md | 56 ++++ .../admin-directory-groups.openapi.yaml | 135 +++++++++ .../data-model.md | 56 ++++ specs/051-entra-group-directory-cache/plan.md | 141 ++++++++++ .../quickstart.md | 42 +++ .../research.md | 42 +++ specs/051-entra-group-directory-cache/spec.md | 236 ++++++++++++++++ .../051-entra-group-directory-cache/tasks.md | 184 ++++++++++++ .../DirectoryGroups/BrowseGroupsTest.php | 114 ++++++++ .../NoLiveGraphOnRenderTest.php | 59 ++++ .../ScheduledSyncDispatchTest.php | 38 +++ .../Feature/DirectoryGroups/StartSyncTest.php | 24 ++ .../SyncJobUpsertsGroupsTest.php | 88 ++++++ .../SyncRetentionPurgeTest.php | 51 ++++ .../EntraGroupSyncRunResourceTest.php | 98 +++++++ tests/Feature/RestoreGroupMappingTest.php | 29 ++ tests/Unit/BulkOperationRunProgressTest.php | 30 ++ .../EntraGroupLabelResolverTest.php | 50 ++++ 57 files changed, 3633 insertions(+), 141 deletions(-) create mode 100644 app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php create mode 100644 app/Filament/Resources/EntraGroupResource.php create mode 100644 app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php create mode 100644 app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php create mode 100644 app/Filament/Resources/EntraGroupSyncRunResource.php create mode 100644 app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php create mode 100644 app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php create mode 100644 app/Jobs/EntraGroupSyncJob.php create mode 100644 app/Livewire/EntraGroupCachePickerTable.php create mode 100644 app/Models/EntraGroup.php create mode 100644 app/Models/EntraGroupSyncRun.php create mode 100644 app/Policies/EntraGroupPolicy.php create mode 100644 app/Policies/EntraGroupSyncRunPolicy.php create mode 100644 app/Services/Directory/EntraGroupLabelResolver.php create mode 100644 app/Services/Directory/EntraGroupSelection.php create mode 100644 app/Services/Directory/EntraGroupSyncService.php create mode 100644 config/directory_groups.php create mode 100644 database/factories/EntraGroupFactory.php create mode 100644 database/factories/EntraGroupSyncRunFactory.php create mode 100644 database/migrations/2026_01_11_120003_create_entra_groups_table.php create mode 100644 database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php create mode 100644 resources/views/filament/modals/entra-group-cache-picker.blade.php create mode 100644 resources/views/livewire/entra-group-cache-picker-table.blade.php create mode 100644 specs/051-entra-group-directory-cache/checklists/pr-gate.md create mode 100644 specs/051-entra-group-directory-cache/checklists/requirements.md create mode 100644 specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml create mode 100644 specs/051-entra-group-directory-cache/data-model.md create mode 100644 specs/051-entra-group-directory-cache/plan.md create mode 100644 specs/051-entra-group-directory-cache/quickstart.md create mode 100644 specs/051-entra-group-directory-cache/research.md create mode 100644 specs/051-entra-group-directory-cache/spec.md create mode 100644 specs/051-entra-group-directory-cache/tasks.md create mode 100644 tests/Feature/DirectoryGroups/BrowseGroupsTest.php create mode 100644 tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php create mode 100644 tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php create mode 100644 tests/Feature/DirectoryGroups/StartSyncTest.php create mode 100644 tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php create mode 100644 tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php create mode 100644 tests/Feature/Filament/EntraGroupSyncRunResourceTest.php create mode 100644 tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php diff --git a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php new file mode 100644 index 0000000..8c684cc --- /dev/null +++ b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php @@ -0,0 +1,116 @@ +isDueAt($now, $timeUtc)) { + return self::SUCCESS; + } + + if (! class_exists(\App\Jobs\EntraGroupSyncJob::class)) { + $this->warn('EntraGroupSyncJob is not available; skipping scheduled directory group sync dispatch.'); + + return self::SUCCESS; + } + + $tenantIdentifiers = array_values(array_filter(array_map('strval', array_merge( + (array) $this->option('tenant'), + (array) config('directory_groups.schedule.tenants', []), + )))); + + $tenants = $this->resolveTenants($tenantIdentifiers); + + $selectionKey = 'groups-v1:all'; + $slotKey = $now->format('YmdHi').'Z'; + + $created = 0; + $skipped = 0; + + foreach ($tenants as $tenant) { + $inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => $slotKey, + 'status' => 'pending', + 'initiator_user_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($inserted === 1) { + $created++; + + dispatch(new \App\Jobs\EntraGroupSyncJob( + tenantId: $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: $slotKey, + )); + } else { + $skipped++; + } + } + + $this->info(sprintf( + 'Scanned %d tenant(s), created %d run(s), skipped %d duplicate run(s).', + $tenants->count(), + $created, + $skipped, + )); + + return self::SUCCESS; + } + + /** + * @param array $tenantIdentifiers + */ + private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection + { + $query = Tenant::activeQuery(); + + if ($tenantIdentifiers !== []) { + $query->where(function ($subQuery) use ($tenantIdentifiers) { + foreach ($tenantIdentifiers as $identifier) { + if (ctype_digit($identifier)) { + $subQuery->orWhereKey((int) $identifier); + + continue; + } + + $subQuery->orWhere('tenant_id', $identifier) + ->orWhere('external_id', $identifier); + } + }); + } + + return $query->get(); + } + + private function isDueAt(CarbonImmutable $now, string $timeUtc): bool + { + if (! preg_match('/^(?[01]\\d|2[0-3]):(?[0-5]\\d)$/', $timeUtc, $matches)) { + return false; + } + + return (int) $matches['hour'] === (int) $now->format('H') + && (int) $matches['minute'] === (int) $now->format('i'); + } +} diff --git a/app/Filament/Resources/EntraGroupResource.php b/app/Filament/Resources/EntraGroupResource.php new file mode 100644 index 0000000..2479125 --- /dev/null +++ b/app/Filament/Resources/EntraGroupResource.php @@ -0,0 +1,211 @@ +schema([ + Section::make('Group') + ->schema([ + TextEntry::make('display_name')->label('Name'), + TextEntry::make('entra_id')->label('Entra ID')->copyable(), + TextEntry::make('type') + ->badge() + ->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))), + TextEntry::make('security_enabled')->label('Security')->badge(), + TextEntry::make('mail_enabled')->label('Mail')->badge(), + TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Raw groupTypes') + ->schema([ + ViewEntry::make('group_types') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (EntraGroup $record) => $record->group_types ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('display_name') + ->modifyQueryUsing(function (Builder $query): Builder { + $tenantId = Tenant::current()?->getKey(); + + return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + }) + ->columns([ + Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), + Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(), + Tables\Columns\TextColumn::make('type') + ->label('Type') + ->badge() + ->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))) + ->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))), + Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'), + ]) + ->filters([ + SelectFilter::make('stale') + ->label('Stale') + ->options([ + '1' => 'Stale', + '0' => 'Fresh', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + + if ((string) $value === '1') { + return $query->where(function (Builder $q) use ($cutoff): void { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $cutoff); + }); + } + + return $query->where('last_seen_at', '>=', $cutoff); + }), + + SelectFilter::make('group_type') + ->label('Type') + ->options([ + 'security' => 'Security', + 'microsoft365' => 'Microsoft 365', + 'mail' => 'Mail-enabled', + 'unknown' => 'Unknown', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? ''); + + if ($value === '') { + return $query; + } + + return match ($value) { + 'microsoft365' => $query->whereJsonContains('group_types', 'Unified'), + 'security' => $query + ->where('security_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'mail' => $query + ->where('mail_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'unknown' => $query + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }) + ->where('security_enabled', false) + ->where('mail_enabled', false), + default => $query, + }; + }), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->latest('id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEntraGroups::route('/'), + 'view' => Pages\ViewEntraGroup::route('/{record}'), + ]; + } + + private static function groupType(EntraGroup $record): string + { + $groupTypes = $record->group_types; + + if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) { + return 'microsoft365'; + } + + if ($record->security_enabled) { + return 'security'; + } + + if ($record->mail_enabled) { + return 'mail'; + } + + return 'unknown'; + } + + private static function groupTypeLabel(string $type): string + { + return match ($type) { + 'microsoft365' => 'Microsoft 365', + 'security' => 'Security', + 'mail' => 'Mail-enabled', + default => 'Unknown', + }; + } + + private static function groupTypeColor(string $type): string + { + return match ($type) { + 'microsoft365' => 'info', + 'security' => 'success', + 'mail' => 'warning', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php new file mode 100644 index 0000000..6079093 --- /dev/null +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -0,0 +1,119 @@ +label('Group Sync Runs') + ->icon('heroicon-o-clock') + ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => (bool) Tenant::current()), + + Action::make('sync_groups') + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + $role = $user->tenantRole($tenant); + + return $role?->canSync() ?? false; + }) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::current(); + + if (! $tenant) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(403); + } + + $role = $user->tenantRole($tenant); + + if (! ($role?->canSync() ?? false)) { + abort(403); + } + + $selectionKey = EntraGroupSelection::allGroupsV1(); + + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $existing->getKey(), + 'status' => $normalizedStatus, + ])); + + return; + } + + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', + ])); + }), + ]; + } +} diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php b/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php new file mode 100644 index 0000000..c7098c9 --- /dev/null +++ b/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php @@ -0,0 +1,11 @@ +schema([ + Section::make('Sync Run') + ->schema([ + TextEntry::make('initiator.name') + ->label('Initiator') + ->placeholder('—'), + TextEntry::make('status') + ->badge() + ->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)), + TextEntry::make('selection_key')->label('Selection'), + TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(), + TextEntry::make('started_at')->dateTime(), + TextEntry::make('finished_at')->dateTime(), + TextEntry::make('pages_fetched')->label('Pages')->numeric(), + TextEntry::make('items_observed_count')->label('Observed')->numeric(), + TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), + TextEntry::make('error_count')->label('Errors')->numeric(), + TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(), + TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Error Summary') + ->schema([ + TextEntry::make('error_code')->placeholder('—'), + TextEntry::make('error_category')->placeholder('—'), + ViewEntry::make('error_summary') + ->label('Safe error summary') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : []) + ->columnSpanFull(), + ]) + ->columns(2) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->modifyQueryUsing(function (Builder $query): Builder { + $tenantId = Tenant::current()?->getKey(); + + return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + }) + ->columns([ + Tables\Columns\TextColumn::make('initiator.name') + ->label('Initiator') + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)), + Tables\Columns\TextColumn::make('selection_key') + ->label('Selection') + ->limit(24) + ->copyable(), + Tables\Columns\TextColumn::make('slot_key') + ->label('Slot') + ->placeholder('—') + ->limit(16) + ->copyable(), + Tables\Columns\TextColumn::make('started_at')->since(), + Tables\Columns\TextColumn::make('finished_at')->since(), + Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(), + Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(), + Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(), + Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->with('initiator') + ->latest('id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEntraGroupSyncRuns::route('/'), + 'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'), + ]; + } + + private static function statusColor(?string $status): string + { + return match ($status) { + EntraGroupSyncRun::STATUS_SUCCEEDED => 'success', + EntraGroupSyncRun::STATUS_PARTIAL => 'warning', + EntraGroupSyncRun::STATUS_FAILED => 'danger', + EntraGroupSyncRun::STATUS_RUNNING => 'info', + EntraGroupSyncRun::STATUS_PENDING => 'gray', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php new file mode 100644 index 0000000..855fbab --- /dev/null +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -0,0 +1,112 @@ +label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + $role = $user->tenantRole($tenant); + + return $role?->canSync() ?? false; + }) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::current(); + + if (! $tenant) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(403); + } + + $role = $user->tenantRole($tenant); + + if (! ($role?->canSync() ?? false)) { + abort(403); + } + + $selectionKey = EntraGroupSelection::allGroupsV1(); + + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $existing->getKey(), + 'status' => $normalizedStatus, + ])); + + return; + } + + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', + ])); + }), + ]; + } +} diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php new file mode 100644 index 0000000..d48840f --- /dev/null +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php @@ -0,0 +1,11 @@ +where('tenant_id', $tenant->getKey()); + $hasCachedGroups = $groupCacheQuery->exists(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + + $cacheNotice = match (true) { + ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', + $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", + default => null, + }; + + return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; @@ -120,7 +136,28 @@ public static function form(Schema $schema): Schema ->placeholder('SKIP or target group Object ID (GUID)') ->rules([new SkipOrUuidRule]) ->required() - ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Select from Directory cache') + ->modalHeading('Select from Directory cache') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + ])) + ) + ->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') + ->hintAction( + Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => $cacheNotice !== null) + ); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -317,7 +354,21 @@ public static function getWizardSteps(): array tenant: $tenant ); - return array_map(function (array $group): Forms\Components\TextInput { + $groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey()); + $hasCachedGroups = $groupCacheQuery->exists(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + + $cacheNotice = match (true) { + ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', + $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", + default => null, + }; + + return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; @@ -335,7 +386,28 @@ public static function getWizardSteps(): array $set('preview_ran_at', null); }) ->required() - ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Select from Directory cache') + ->modalHeading('Select from Directory cache') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + ])) + ) + ->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') + ->hintAction( + Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => $cacheNotice !== null) + ); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -1541,10 +1613,16 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return []; } - return array_map(function (string $groupId) use ($sourceNames): array { + $resolver = app(EntraGroupLabelResolver::class); + $cached = $resolver->lookupMany($tenant, $groupIds); + + return array_map(function (string $groupId) use ($sourceNames, $cached): array { + $cachedName = $cached[strtolower($groupId)] ?? null; + $fallbackName = $cachedName ?? ($sourceNames[$groupId] ?? null); + return [ 'id' => $groupId, - 'label' => static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId), + 'label' => EntraGroupLabelResolver::formatLabel($fallbackName, $groupId), ]; }, $groupIds); } @@ -1653,11 +1731,4 @@ private static function normalizeGroupMapping(mixed $mapping): array return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== ''); } - - private static function formatGroupLabel(?string $displayName, string $id): string - { - $suffix = '…'.mb_substr($id, -8); - - return trim(($displayName ?: 'Security group').' ('.$suffix.')'); - } } diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2e4e6de..b799644 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -9,6 +9,7 @@ use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; +use Livewire\Attributes\On; class CreateRestoreRun extends CreateRecord { @@ -119,4 +120,23 @@ protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); } + + #[On('entra-group-cache-picked')] + public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void + { + data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId); + + $this->data['check_summary'] = null; + $this->data['check_results'] = []; + $this->data['checks_ran_at'] = null; + $this->data['preview_summary'] = null; + $this->data['preview_diffs'] = []; + $this->data['preview_ran_at'] = null; + + $this->form->fill($this->data); + + if (method_exists($this, 'unmountAction')) { + $this->unmountAction(); + } + } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index f32bd90..f5efe27 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -6,9 +6,11 @@ use App\Http\Controllers\RbacDelegatedAuthController; use App\Jobs\BulkTenantSyncJob; use App\Jobs\SyncPoliciesJob; +use App\Models\EntraGroup; use App\Models\Tenant; use App\Models\User; use App\Services\BulkOperationService; +use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacHealthService; @@ -585,10 +587,7 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('scope') === 'scope_group') ->required(fn (Get $get) => $get('scope') === 'scope_group') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) - ->hint(fn (?Tenant $record) => static::groupSearchHelper($record)) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) ->noSearchResultsMessage('No security groups found') @@ -615,10 +614,7 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('group_mode') === 'existing') ->required(fn (Get $get) => $get('group_mode') === 'existing') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) - ->hint(fn (?Tenant $record) => static::groupSearchHelper($record)) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) ->noSearchResultsMessage('No security groups found') @@ -928,6 +924,11 @@ private static function formatRoleLabel(?string $displayName, string $id): strin return trim(($displayName ?: 'RBAC role').$suffix); } + private static function escapeOdataValue(string $value): string + { + return str_replace("'", "''", $value); + } + private static function notifyRoleLookupFailure(): void { Notification::make() @@ -980,65 +981,30 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act public static function groupSearchHelper(?Tenant $tenant): ?string { - return static::delegatedToken($tenant) ? null : 'Login to search groups'; + if (! $tenant) { + return null; + } + + return 'Uses cached directory groups only (no live Graph lookups). Run “Sync Groups” if results are empty.'; } /** * @return array */ public static function groupSearchOptions(?Tenant $tenant, string $search): array - { - return static::searchSecurityGroups($tenant, $search); - } - - /** - * @return array - */ - private static function searchSecurityGroups(?Tenant $tenant, string $search): array { if (! $tenant || mb_strlen($search) < 2) { return []; } - $token = static::delegatedToken($tenant); - - if (! $token) { - return []; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups', - [ - 'query' => [ - '$filter' => sprintf( - "securityEnabled eq true and startswith(displayName,'%s')", - static::escapeOdataValue($search) - ), - '$select' => 'id,displayName', - '$top' => 20, - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyGroupLookupFailure(); - - return []; - } - - if ($response->failed()) { - static::notifyGroupLookupFailure(); - - return []; - } - - return collect($response->data['value'] ?? []) - ->filter(fn (array $group) => filled($group['id'] ?? null)) - ->mapWithKeys(fn (array $group) => [ - $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']), + return EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->where('display_name', 'ilike', '%'.str_replace('%', '\\%', $search).'%') + ->orderBy('display_name') + ->limit(20) + ->get(['entra_id', 'display_name']) + ->mapWithKeys(fn (EntraGroup $group) => [ + (string) $group->entra_id => EntraGroupLabelResolver::formatLabel($group->display_name, (string) $group->entra_id), ]) ->all(); } @@ -1049,61 +1015,7 @@ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?s return $groupId; } - $token = static::delegatedToken($tenant); - - if (! $token) { - return $groupId; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - "groups/{$groupId}", - [ - 'query' => [ - '$select' => 'id,displayName', - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyGroupLookupFailure(); - - return $groupId; - } - - if ($response->failed()) { - static::notifyGroupLookupFailure(); - - return $groupId; - } - - $displayName = $response->data['displayName'] ?? null; - $id = $response->data['id'] ?? $groupId; - - return static::formatGroupLabel($displayName, $id); - } - - private static function formatGroupLabel(?string $displayName, string $id): string - { - $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); - - return trim(($displayName ?: 'Security group').$suffix); - } - - private static function escapeOdataValue(string $value): string - { - return str_replace("'", "''", $value); - } - - private static function notifyGroupLookupFailure(): void - { - Notification::make() - ->title('Group lookup failed') - ->body('Delegated session may have expired. Login again to search security groups.') - ->danger() - ->send(); + return app(EntraGroupLabelResolver::class)->resolveOne($tenant, $groupId); } public static function verifyTenant( diff --git a/app/Jobs/EntraGroupSyncJob.php b/app/Jobs/EntraGroupSyncJob.php new file mode 100644 index 0000000..cb52e8a --- /dev/null +++ b/app/Jobs/EntraGroupSyncJob.php @@ -0,0 +1,139 @@ +find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $run = $this->resolveRun($tenant); + + if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) { + return; + } + + $run->update([ + 'status' => EntraGroupSyncRun::STATUS_RUNNING, + 'started_at' => CarbonImmutable::now('UTC'), + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'directory_groups.sync.started', + context: [ + 'selection_key' => $run->selection_key, + 'run_id' => $run->getKey(), + 'slot_key' => $run->slot_key, + ], + actorId: $run->initiator_user_id, + status: 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $run->getKey(), + ); + + $result = $syncService->sync($tenant, $run); + + $terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED; + + if ($result['error_code'] !== null) { + $terminalStatus = EntraGroupSyncRun::STATUS_FAILED; + } elseif ($result['safety_stop_triggered'] === true) { + $terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL; + } + + $run->update([ + 'status' => $terminalStatus, + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_count' => $result['error_count'], + 'safety_stop_triggered' => $result['safety_stop_triggered'], + 'safety_stop_reason' => $result['safety_stop_reason'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], + 'error_summary' => $result['error_summary'], + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + $auditLogger->log( + tenant: $tenant, + action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED + ? 'directory_groups.sync.succeeded' + : ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL + ? 'directory_groups.sync.partial' + : 'directory_groups.sync.failed'), + context: [ + 'selection_key' => $run->selection_key, + 'run_id' => $run->getKey(), + 'slot_key' => $run->slot_key, + 'pages_fetched' => $run->pages_fetched, + 'items_observed_count' => $run->items_observed_count, + 'items_upserted_count' => $run->items_upserted_count, + 'error_code' => $run->error_code, + 'error_category' => $run->error_category, + ], + actorId: $run->initiator_user_id, + status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $run->getKey(), + ); + } + + private function resolveRun(Tenant $tenant): EntraGroupSyncRun + { + if ($this->runId !== null) { + $run = EntraGroupSyncRun::query() + ->whereKey($this->runId) + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if ($run instanceof EntraGroupSyncRun) { + return $run; + } + + throw new RuntimeException('EntraGroupSyncRun not found.'); + } + + if ($this->slotKey !== null) { + $run = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $this->selectionKey) + ->where('slot_key', $this->slotKey) + ->first(); + + if ($run instanceof EntraGroupSyncRun) { + return $run; + } + + throw new RuntimeException('EntraGroupSyncRun not found for slot.'); + } + + throw new RuntimeException('Job missing runId/slotKey.'); + } +} diff --git a/app/Livewire/EntraGroupCachePickerTable.php b/app/Livewire/EntraGroupCachePickerTable.php new file mode 100644 index 0000000..f79834d --- /dev/null +++ b/app/Livewire/EntraGroupCachePickerTable.php @@ -0,0 +1,203 @@ +sourceGroupId = $sourceGroupId; + } + + public function table(Table $table): Table + { + $tenantId = Tenant::current()?->getKey(); + + $query = EntraGroup::query(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereRaw('1 = 0'); + } + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + + return $table + ->queryStringIdentifier('entraGroupCachePicker') + ->query($query) + ->defaultSort('display_name') + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->searchable() + ->searchPlaceholder('Search groups…') + ->deferLoading(! app()->runningUnitTests()) + ->columns([ + TextColumn::make('display_name') + ->label('Name') + ->searchable() + ->sortable() + ->wrap() + ->limit(60), + TextColumn::make('type') + ->label('Type') + ->badge() + ->state(fn (EntraGroup $record): string => $this->groupTypeLabel($this->groupType($record))) + ->color(fn (EntraGroup $record): string => $this->groupTypeColor($this->groupType($record))) + ->toggleable(), + TextColumn::make('entra_id') + ->label('ID') + ->formatStateUsing(fn (?string $state): string => filled($state) ? ('…'.substr($state, -8)) : '—') + ->extraAttributes(['class' => 'font-mono']) + ->toggleable(), + TextColumn::make('last_seen_at') + ->label('Last seen') + ->since() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('stale') + ->label('Stale') + ->options([ + '1' => 'Stale', + '0' => 'Fresh', + ]) + ->query(function (Builder $query, array $data) use ($cutoff): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + if ((string) $value === '1') { + return $query->where(function (Builder $q) use ($cutoff): void { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $cutoff); + }); + } + + return $query->where('last_seen_at', '>=', $cutoff); + }), + + SelectFilter::make('group_type') + ->label('Type') + ->options([ + 'security' => 'Security', + 'microsoft365' => 'Microsoft 365', + 'mail' => 'Mail-enabled', + 'unknown' => 'Unknown', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? ''); + + if ($value === '') { + return $query; + } + + return match ($value) { + 'microsoft365' => $query->whereJsonContains('group_types', 'Unified'), + 'security' => $query + ->where('security_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'mail' => $query + ->where('mail_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'unknown' => $query + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }) + ->where('security_enabled', false) + ->where('mail_enabled', false), + default => $query, + }; + }), + ]) + ->actions([ + Action::make('select') + ->label('Select') + ->icon('heroicon-o-check') + ->color('primary') + ->action(function (EntraGroup $record): void { + $this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id); + }), + ]) + ->emptyStateHeading('No cached groups found') + ->emptyStateDescription('Run “Sync Groups” first, then come back here.') + ->emptyStateActions([ + Action::make('open_groups') + ->label('Directory Groups') + ->icon('heroicon-o-user-group') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())), + Action::make('open_sync_runs') + ->label('Group Sync Runs') + ->icon('heroicon-o-clock') + ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())), + ]); + } + + public function render(): View + { + return view('livewire.entra-group-cache-picker-table'); + } + + private function groupType(EntraGroup $record): string + { + $groupTypes = $record->group_types; + + if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) { + return 'microsoft365'; + } + + if ($record->security_enabled) { + return 'security'; + } + + if ($record->mail_enabled) { + return 'mail'; + } + + return 'unknown'; + } + + private function groupTypeLabel(string $type): string + { + return match ($type) { + 'microsoft365' => 'Microsoft 365', + 'security' => 'Security', + 'mail' => 'Mail-enabled', + default => 'Unknown', + }; + } + + private function groupTypeColor(string $type): string + { + return match ($type) { + 'microsoft365' => 'info', + 'security' => 'success', + 'mail' => 'warning', + default => 'gray', + }; + } +} diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php index 4067708..12d66de 100644 --- a/app/Livewire/PolicyVersionAssignmentsWidget.php +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -3,6 +3,8 @@ namespace App\Livewire; use App\Models\PolicyVersion; +use App\Models\Tenant; +use App\Services\Directory\EntraGroupLabelResolver; use Livewire\Component; class PolicyVersionAssignmentsWidget extends Component @@ -19,9 +21,75 @@ public function render(): \Illuminate\Contracts\View\View return view('livewire.policy-version-assignments-widget', [ 'version' => $this->version, 'compliance' => $this->complianceNotifications(), + 'groupLabels' => $this->groupLabels(), ]); } + /** + * @return array + */ + private function groupLabels(): array + { + $assignments = $this->version->assignments; + + if (! is_array($assignments) || $assignments === []) { + return []; + } + + $tenant = rescue(fn () => Tenant::current(), null); + + if (! $tenant instanceof Tenant) { + return []; + } + + $groupIds = []; + $sourceNames = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $target = $assignment['target'] ?? null; + + if (! is_array($target)) { + continue; + } + + $groupId = $target['groupId'] ?? null; + + if (! is_string($groupId) || $groupId === '') { + continue; + } + + $groupIds[] = $groupId; + + $displayName = $target['group_display_name'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + $sourceNames[$groupId] = $displayName; + } + } + + $groupIds = array_values(array_unique($groupIds)); + + if ($groupIds === []) { + return []; + } + + $resolver = app(EntraGroupLabelResolver::class); + $cached = $resolver->lookupMany($tenant, $groupIds); + + $labels = []; + + foreach ($groupIds as $groupId) { + $cachedName = $cached[strtolower($groupId)] ?? null; + $labels[$groupId] = EntraGroupLabelResolver::formatLabel($cachedName ?? ($sourceNames[$groupId] ?? null), $groupId); + } + + return $labels; + } + /** * @return array{total:int,templates:array,items:array} */ diff --git a/app/Models/EntraGroup.php b/app/Models/EntraGroup.php new file mode 100644 index 0000000..e8f4b27 --- /dev/null +++ b/app/Models/EntraGroup.php @@ -0,0 +1,26 @@ + 'array', + 'security_enabled' => 'boolean', + 'mail_enabled' => 'boolean', + 'last_seen_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/EntraGroupSyncRun.php b/app/Models/EntraGroupSyncRun.php new file mode 100644 index 0000000..c02f752 --- /dev/null +++ b/app/Models/EntraGroupSyncRun.php @@ -0,0 +1,40 @@ + 'boolean', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function initiator(): BelongsTo + { + return $this->belongsTo(User::class, 'initiator_user_id'); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1294555..a973d2e 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -195,6 +195,16 @@ public function restoreRuns(): HasMany return $this->hasMany(RestoreRun::class); } + public function entraGroups(): HasMany + { + return $this->hasMany(EntraGroup::class); + } + + public function entraGroupSyncRuns(): HasMany + { + return $this->hasMany(EntraGroupSyncRun::class); + } + public function auditLogs(): HasMany { return $this->hasMany(AuditLog::class); diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 2faab26..0a0f6d0 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -3,6 +3,7 @@ namespace App\Notifications; use App\Filament\Resources\BulkOperationRunResource; +use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\RestoreRunResource; use App\Models\Tenant; use Filament\Actions\Action; @@ -60,13 +61,34 @@ public function toDatabase(object $notifiable): array $actions = []; - if (in_array($runType, ['bulk_operation', 'restore'], true) && $tenantId > 0 && $runId > 0) { + if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) { $tenant = Tenant::query()->find($tenantId); if ($tenant) { - $url = $runType === 'bulk_operation' - ? BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant) - : RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant); + $url = match ($runType) { + 'bulk_operation' => BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + default => null, + }; + + if (! $url) { + return [ + 'format' => 'filament', + 'title' => $title, + 'body' => $body, + 'color' => $color, + 'duration' => 'persistent', + 'actions' => [], + 'icon' => null, + 'iconColor' => null, + 'status' => null, + 'view' => null, + 'viewData' => [ + 'metadata' => $this->metadata, + ], + ]; + } $actions[] = Action::make('view_run') ->label('View run') diff --git a/app/Policies/EntraGroupPolicy.php b/app/Policies/EntraGroupPolicy.php new file mode 100644 index 0000000..24b88dd --- /dev/null +++ b/app/Policies/EntraGroupPolicy.php @@ -0,0 +1,39 @@ +canAccessTenant($tenant); + } + + public function view(User $user, EntraGroup $group): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $group->tenant_id === (int) $tenant->getKey(); + } +} diff --git a/app/Policies/EntraGroupSyncRunPolicy.php b/app/Policies/EntraGroupSyncRunPolicy.php new file mode 100644 index 0000000..09865da --- /dev/null +++ b/app/Policies/EntraGroupSyncRunPolicy.php @@ -0,0 +1,39 @@ +canAccessTenant($tenant); + } + + public function view(User $user, EntraGroupSyncRun $run): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $run->tenant_id === (int) $tenant->getKey(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a605e4a..1ffcf4a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -108,5 +108,7 @@ public function boot(): void Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class); + Gate::policy(\App\Models\EntraGroupSyncRun::class, \App\Policies\EntraGroupSyncRunPolicy::class); + Gate::policy(\App\Models\EntraGroup::class, \App\Policies\EntraGroupPolicy::class); } } diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index b85193a..6530c55 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -45,6 +45,8 @@ public function createRun( array $itemIds, int $totalItems ): BulkOperationRun { + $effectiveTotalItems = max($totalItems, count($itemIds)); + $run = BulkOperationRun::create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, @@ -52,7 +54,7 @@ public function createRun( 'action' => $action, 'status' => 'pending', 'item_ids' => $itemIds, - 'total_items' => $totalItems, + 'total_items' => $effectiveTotalItems, 'processed_items' => 0, 'succeeded' => 0, 'failed' => 0, @@ -66,7 +68,7 @@ public function createRun( context: [ 'metadata' => [ 'bulk_run_id' => $run->id, - 'total_items' => $totalItems, + 'total_items' => $effectiveTotalItems, ], ], actorId: $user->id, @@ -139,6 +141,14 @@ public function complete(BulkOperationRun $run): void { $run->refresh(); + if ($run->processed_items > $run->total_items) { + BulkOperationRun::query() + ->whereKey($run->id) + ->update(['total_items' => $run->processed_items]); + + $run->refresh(); + } + if (! in_array($run->status, ['pending', 'running'], true)) { return; } diff --git a/app/Services/Directory/EntraGroupLabelResolver.php b/app/Services/Directory/EntraGroupLabelResolver.php new file mode 100644 index 0000000..0691983 --- /dev/null +++ b/app/Services/Directory/EntraGroupLabelResolver.php @@ -0,0 +1,97 @@ +resolveMany($tenant, [$groupId]); + + return $labels[$groupId] ?? self::formatLabel(null, $groupId); + } + + /** + * @param array $groupIds + * @return array + */ + public function resolveMany(Tenant $tenant, array $groupIds): array + { + $groupIds = array_values(array_unique(array_filter($groupIds, fn ($id) => is_string($id) && $id !== ''))); + + if ($groupIds === []) { + return []; + } + + $displayNames = $this->lookupMany($tenant, $groupIds); + + $labels = []; + + foreach ($groupIds as $groupId) { + $lookupId = Str::isUuid($groupId) ? strtolower($groupId) : $groupId; + $labels[$groupId] = self::formatLabel($displayNames[$lookupId] ?? null, $groupId); + } + + return $labels; + } + + /** + * @param array $groupIds + * @return array Map of groupId (lowercased UUID) => display_name + */ + public function lookupMany(Tenant $tenant, array $groupIds): array + { + $uuids = []; + + foreach ($groupIds as $groupId) { + if (! is_string($groupId) || $groupId === '') { + continue; + } + + if (! Str::isUuid($groupId)) { + continue; + } + + $uuids[] = strtolower($groupId); + } + + $uuids = array_values(array_unique($uuids)); + + if ($uuids === []) { + return []; + } + + return EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('entra_id', $uuids) + ->pluck('display_name', 'entra_id') + ->mapWithKeys(fn (string $displayName, string $entraId) => [strtolower($entraId) => $displayName]) + ->all(); + } + + public static function formatLabel(?string $displayName, string $id): string + { + $name = filled($displayName) ? $displayName : 'Unresolved'; + + return sprintf('%s (%s)', trim($name), self::shortToken($id)); + } + + private static function shortToken(string $id): string + { + $normalized = preg_replace('/[^a-zA-Z0-9]/', '', $id); + + if (! is_string($normalized) || $normalized === '') { + return 'unknown'; + } + + if (mb_strlen($normalized) <= 8) { + return $normalized; + } + + return '…'.mb_substr($normalized, -8); + } +} diff --git a/app/Services/Directory/EntraGroupSelection.php b/app/Services/Directory/EntraGroupSelection.php new file mode 100644 index 0000000..a9e0e64 --- /dev/null +++ b/app/Services/Directory/EntraGroupSelection.php @@ -0,0 +1,20 @@ +format('YmdHi').'Z'; + } +} diff --git a/app/Services/Directory/EntraGroupSyncService.php b/app/Services/Directory/EntraGroupSyncService.php new file mode 100644 index 0000000..e3a437e --- /dev/null +++ b/app/Services/Directory/EntraGroupSyncService.php @@ -0,0 +1,262 @@ +where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + return $existing; + } + + $run = EntraGroupSyncRun::create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new \App\Jobs\EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); + + return $run; + } + + /** + * @return array{ + * pages_fetched:int, + * items_observed_count:int, + * items_upserted_count:int, + * error_count:int, + * safety_stop_triggered:bool, + * safety_stop_reason:?string, + * error_code:?string, + * error_category:?string, + * error_summary:?string + * } + */ + public function sync(Tenant $tenant, EntraGroupSyncRun $run): array + { + $nowUtc = CarbonImmutable::now('UTC'); + + $policyType = $this->contracts->directoryGroupsPolicyType(); + $path = $this->contracts->directoryGroupsListPath(); + + $contract = $this->contracts->get($policyType); + $query = []; + + if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) { + $query['$select'] = $contract['allowed_select']; + } + + $pageSize = (int) config('directory_groups.page_size', 999); + if ($pageSize > 0) { + $query['$top'] = $pageSize; + } + + $sanitized = $this->contracts->sanitizeQuery($policyType, $query); + $query = $sanitized['query']; + + $maxPages = (int) config('directory_groups.safety_stop.max_pages', 200); + $maxRuntimeSeconds = (int) config('directory_groups.safety_stop.max_runtime_seconds', 600); + $deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds)); + + $pagesFetched = 0; + $observed = 0; + $upserted = 0; + + $safetyStopTriggered = false; + $safetyStopReason = null; + + $errorCode = null; + $errorCategory = null; + $errorSummary = null; + $errorCount = 0; + + $options = $tenant->graphOptions(); + $useQuery = $query; + $nextPath = $path; + + while ($nextPath) { + if (CarbonImmutable::now('UTC')->greaterThan($deadline)) { + $safetyStopTriggered = true; + $safetyStopReason = 'runtime_exceeded'; + + break; + } + + if ($pagesFetched >= $maxPages) { + $safetyStopTriggered = true; + $safetyStopReason = 'max_pages_exceeded'; + + break; + } + + $response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]); + + if ($response->failed()) { + [$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response); + $errorCount = 1; + + break; + } + + $pagesFetched++; + + $data = $response->data; + $pageItems = $data['value'] ?? (is_array($data) ? $data : []); + + if (is_array($pageItems)) { + foreach ($pageItems as $item) { + if (! is_array($item)) { + continue; + } + + $entraId = $item['id'] ?? null; + if (! is_string($entraId) || $entraId === '') { + continue; + } + + $displayName = $item['displayName'] ?? null; + $groupTypes = $item['groupTypes'] ?? null; + + $values = [ + 'display_name' => is_string($displayName) ? $displayName : $entraId, + 'group_types' => is_array($groupTypes) ? $groupTypes : [], + 'security_enabled' => (bool) ($item['securityEnabled'] ?? false), + 'mail_enabled' => (bool) ($item['mailEnabled'] ?? false), + 'last_seen_at' => $nowUtc, + ]; + + EntraGroup::query()->updateOrCreate([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => $entraId, + ], $values); + + $observed++; + $upserted++; + } + } + + $nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null; + + if (! is_string($nextLink) || $nextLink === '') { + break; + } + + $nextPath = $this->stripGraphBaseUrl($nextLink); + $useQuery = []; + } + + $retentionDays = (int) config('directory_groups.retention_days', 90); + if ($retentionDays > 0) { + $cutoff = $nowUtc->subDays($retentionDays); + + EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->whereNotNull('last_seen_at') + ->where('last_seen_at', '<', $cutoff) + ->delete(); + } + + return [ + 'pages_fetched' => $pagesFetched, + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'error_count' => $errorCount, + 'safety_stop_triggered' => $safetyStopTriggered, + 'safety_stop_reason' => $safetyStopReason, + 'error_code' => $errorCode, + 'error_category' => $errorCategory, + 'error_summary' => $errorSummary, + ]; + } + + private function requestWithRetry(string $method, string $path, array $options): GraphResponse + { + $maxRetries = (int) config('directory_groups.safety_stop.max_retries', 8); + $maxRetries = max(0, $maxRetries); + + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + $response = $this->graph->request($method, $path, $options); + + if ($response->successful()) { + return $response; + } + + $status = (int) ($response->status ?? 0); + + if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) { + return $response; + } + + $baseDelaySeconds = min(30, 1 << $attempt); + $jitterMillis = random_int(0, 250); + usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000); + } + + return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]); + } + + /** + * @return array{0:string,1:string,2:string} + */ + private function categorizeError(GraphResponse $response): array + { + $status = (int) ($response->status ?? 0); + + if (in_array($status, [401, 403], true)) { + return ['permission_denied', 'permission', 'Graph permission denied for groups listing.']; + } + + if ($status === 429) { + return ['throttled', 'throttling', 'Graph throttled the groups listing request.']; + } + + if (in_array($status, [500, 502, 503, 504], true)) { + return ['graph_unavailable', 'transient', 'Graph returned a transient server error.']; + } + + return ['graph_request_failed', 'unknown', 'Graph request failed.']; + } + + private function stripGraphBaseUrl(string $nextLink): string + { + $base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim((string) config('graph.version', 'v1.0'), '/'); + + if (str_starts_with($nextLink, $base)) { + return ltrim((string) substr($nextLink, strlen($base)), '/'); + } + + return ltrim($nextLink, '/'); + } +} diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 22f85c9..467899b 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -6,6 +6,18 @@ class GraphContractRegistry { + public function directoryGroupsPolicyType(): string + { + return 'directoryGroups'; + } + + public function directoryGroupsListPath(): string + { + $resource = $this->resourcePath($this->directoryGroupsPolicyType()) ?? 'groups'; + + return '/'.ltrim($resource, '/'); + } + /** * @return array */ diff --git a/config/directory_groups.php b/config/directory_groups.php new file mode 100644 index 0000000..7577957 --- /dev/null +++ b/config/directory_groups.php @@ -0,0 +1,25 @@ + (int) env('DIRECTORY_GROUPS_STALENESS_DAYS', 30), + + 'retention_days' => (int) env('DIRECTORY_GROUPS_RETENTION_DAYS', 90), + + 'page_size' => (int) env('DIRECTORY_GROUPS_PAGE_SIZE', 999), + + 'schedule' => [ + 'enabled' => (bool) env('DIRECTORY_GROUPS_SCHEDULE_ENABLED', true), + + // Daily scheduled sync (UTC). The dispatcher is still expected to run every minute. + 'time_utc' => (string) env('DIRECTORY_GROUPS_SCHEDULE_TIME_UTC', '02:00'), + + // Optional: limit scheduled dispatch to specific tenant ids/external ids (comma-separated). + 'tenants' => array_values(array_filter(array_map('trim', explode(',', (string) env('DIRECTORY_GROUPS_SCHEDULE_TENANTS', ''))))), + ], + + 'safety_stop' => [ + 'max_pages' => (int) env('DIRECTORY_GROUPS_MAX_PAGES', 200), + 'max_runtime_seconds' => (int) env('DIRECTORY_GROUPS_MAX_RUNTIME_SECONDS', 600), + 'max_retries' => (int) env('DIRECTORY_GROUPS_MAX_RETRIES', 8), + ], +]; diff --git a/config/graph_contracts.php b/config/graph_contracts.php index f1220a5..d140419 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -12,6 +12,11 @@ | */ 'types' => [ + 'directoryGroups' => [ + 'resource' => 'groups', + 'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'], + 'allowed_expand' => [], + ], 'deviceConfiguration' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 21684c1..609aedc 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -72,7 +72,7 @@ 'key' => 'Group.Read.All', 'type' => 'application', 'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.', - 'features' => ['assignments', 'group-mapping', 'backup-metadata'], + 'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache'], ], [ 'key' => 'DeviceManagementScripts.ReadWrite.All', diff --git a/database/factories/EntraGroupFactory.php b/database/factories/EntraGroupFactory.php new file mode 100644 index 0000000..ffc04a6 --- /dev/null +++ b/database/factories/EntraGroupFactory.php @@ -0,0 +1,28 @@ + + */ +class EntraGroupFactory extends Factory +{ + protected $model = EntraGroup::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'entra_id' => fake()->uuid(), + 'display_name' => fake()->company(), + 'group_types' => [], + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now('UTC'), + ]; + } +} diff --git a/database/factories/EntraGroupSyncRunFactory.php b/database/factories/EntraGroupSyncRunFactory.php new file mode 100644 index 0000000..9e5757c --- /dev/null +++ b/database/factories/EntraGroupSyncRunFactory.php @@ -0,0 +1,45 @@ + + */ +class EntraGroupSyncRunFactory extends Factory +{ + protected $model = EntraGroupSyncRun::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => User::factory(), + 'pages_fetched' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'error_count' => 0, + 'safety_stop_triggered' => false, + 'safety_stop_reason' => null, + 'error_code' => null, + 'error_category' => null, + 'error_summary' => null, + 'started_at' => null, + 'finished_at' => null, + ]; + } + + public function scheduled(): static + { + return $this->state(fn (): array => [ + 'initiator_user_id' => null, + ]); + } +} diff --git a/database/migrations/2026_01_11_120003_create_entra_groups_table.php b/database/migrations/2026_01_11_120003_create_entra_groups_table.php new file mode 100644 index 0000000..8aa06a7 --- /dev/null +++ b/database/migrations/2026_01_11_120003_create_entra_groups_table.php @@ -0,0 +1,36 @@ +id(); + + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->uuid('entra_id'); + $table->string('display_name'); + $table->jsonb('group_types')->nullable(); + $table->boolean('security_enabled')->default(false); + $table->boolean('mail_enabled')->default(false); + + $table->timestampTz('last_seen_at')->nullable(); + + $table->timestamps(); + + $table->unique(['tenant_id', 'entra_id']); + $table->index(['tenant_id', 'display_name']); + $table->index(['tenant_id', 'last_seen_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('entra_groups'); + } +}; diff --git a/database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php b/database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php new file mode 100644 index 0000000..72606ed --- /dev/null +++ b/database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php @@ -0,0 +1,51 @@ +id(); + + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->string('selection_key'); + $table->string('slot_key')->nullable(); + + $table->string('status'); + + $table->string('error_code')->nullable(); + $table->string('error_category')->nullable(); + $table->text('error_summary')->nullable(); + + $table->boolean('safety_stop_triggered')->default(false); + $table->string('safety_stop_reason')->nullable(); + + $table->unsignedInteger('pages_fetched')->default(0); + $table->unsignedInteger('items_observed_count')->default(0); + $table->unsignedInteger('items_upserted_count')->default(0); + $table->unsignedInteger('error_count')->default(0); + + $table->foreignId('initiator_user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + + $table->timestamps(); + + $table->index(['tenant_id', 'selection_key']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'finished_at']); + $table->unique(['tenant_id', 'selection_key', 'slot_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('entra_group_sync_runs'); + } +}; diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 96a6b81..8171bde 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -13,6 +13,26 @@ $foundationItems = collect($results)->filter($isFoundationEntry); $policyItems = collect($results)->reject($isFoundationEntry); } + + $tenant = rescue(fn () => \App\Models\Tenant::current(), null); + $groupLabelResolver = $tenant ? app(\App\Services\Directory\EntraGroupLabelResolver::class) : null; + + $formatGroupId = function ($groupId, $fallbackName = null) use ($tenant, $groupLabelResolver) { + if (! is_string($groupId) || $groupId === '') { + return null; + } + + $cachedName = null; + + if ($tenant && $groupLabelResolver) { + $cached = $groupLabelResolver->lookupMany($tenant, [$groupId]); + $cachedName = $cached[strtolower($groupId)] ?? null; + } + + $name = is_string($fallbackName) && $fallbackName !== '' ? $fallbackName : null; + + return \App\Services\Directory\EntraGroupLabelResolver::formatLabel($cachedName ?? $name, $groupId); + }; @endphp @if ($foundationItems->isEmpty() && $policyItems->isEmpty()) @@ -152,12 +172,15 @@ }; $assignmentGroupId = $outcome['group_id'] ?? ($outcome['assignment']['target']['groupId'] ?? null); + $assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null); + $mappedGroupId = $outcome['mapped_group_id'] ?? null; + $mappedGroupLabel = $formatGroupId(is_string($mappedGroupId) ? $mappedGroupId : null); @endphp
- Assignment {{ $assignmentGroupId ?? 'unknown group' }} + Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
{{ $outcomeStatus }} @@ -166,7 +189,7 @@ @if (! empty($outcome['mapped_group_id']))
- Mapped to: {{ $outcome['mapped_group_id'] }} + Mapped to: {{ $mappedGroupLabel ?? $outcome['mapped_group_id'] }}
@endif diff --git a/resources/views/filament/modals/entra-group-cache-picker.blade.php b/resources/views/filament/modals/entra-group-cache-picker.blade.php new file mode 100644 index 0000000..3475afb --- /dev/null +++ b/resources/views/filament/modals/entra-group-cache-picker.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index faf75fb..864d872 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -3,6 +3,8 @@ @if($runs->isNotEmpty())
@foreach ($runs as $run) + @php($effectiveTotal = max((int) $run->total_items, (int) $run->processed_items)) + @php($percent = $effectiveTotal > 0 ? min(100, round(($run->processed_items / $effectiveTotal) * 100)) : 0)
@@ -38,17 +40,17 @@
- {{ $run->processed_items }} / {{ $run->total_items }} + {{ $run->processed_items }} / {{ $effectiveTotal }}
- {{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}% + {{ $percent }}%
+ style="width: {{ $percent }}%">
diff --git a/resources/views/livewire/entra-group-cache-picker-table.blade.php b/resources/views/livewire/entra-group-cache-picker-table.blade.php new file mode 100644 index 0000000..b7a760b --- /dev/null +++ b/resources/views/livewire/entra-group-cache-picker-table.blade.php @@ -0,0 +1,3 @@ +
+ {{ $this->table }} +
diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 5a593c4..65006bf 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -66,21 +66,24 @@ {{ $typeName }} @if($groupId) + @php + $groupLabel = $groupLabels[$groupId] ?? \App\Services\Directory\EntraGroupLabelResolver::formatLabel( + is_string($groupName) ? $groupName : null, + (string) $groupId, + ); + @endphp : @if($groupOrphaned) - ⚠️ Unknown group (ID: {{ $groupId }}) + ⚠️ {{ $groupLabel }} - @elseif($groupName) + @elseif($groupLabel) - {{ $groupName }} - - - ({{ $groupId }}) + {{ $groupLabel }} @else - Group ID: {{ $groupId }} + {{ $groupId }} @endif @endif diff --git a/routes/console.php b/routes/console.php index f2ce44a..49eb3f5 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,4 @@ })->purpose('Display an inspiring quote'); Schedule::command('tenantpilot:schedules:dispatch')->everyMinute(); +Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute(); diff --git a/specs/051-entra-group-directory-cache/checklists/pr-gate.md b/specs/051-entra-group-directory-cache/checklists/pr-gate.md new file mode 100644 index 0000000..fb281ae --- /dev/null +++ b/specs/051-entra-group-directory-cache/checklists/pr-gate.md @@ -0,0 +1,79 @@ +# PR Gate Checklist: Entra Group Directory Cache (Groups v1) + +**Purpose**: PR review gate to validate that the *requirements* in `spec.md` are complete, clear, consistent, measurable, and cover key scenarios for Groups v1. +**Created**: 2026-01-11 +**Feature**: specs/051-entra-group-directory-cache/spec.md + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + +## Requirement Completeness + +- [x] CHK001 Are manual and scheduled start modes explicitly specified as in-scope (and not deferred)? [OK, Evidence: Spec §FR-004b, Spec §Pinned Decisions (v1 defaults)] +- [x] CHK002 Are the cached data fields (metadata-only) explicitly enumerated (e.g., displayName, groupTypes, securityEnabled/mailEnabled) rather than described generically? [OK, Evidence: Spec §FR-001a] +- [x] CHK003 Is the full-tenant scope of enumeration explicitly stated as the v1 source of truth (and not “referenced-only”)? [OK, Evidence: Spec §Clarifications, Spec §FR-004a] +- [x] CHK004 Are retention and purge requirements fully specified, including the retention start point and purge trigger? [OK, Evidence: Spec §FR-005a] +- [x] CHK005 Are audit trail requirements defined beyond “auditable” (what is recorded, where it is visible, minimum fields)? [OK, Evidence: Spec §FR-009a] +- [x] CHK006 Are authorization requirements defined for who may (a) browse groups, (b) trigger sync, (c) view run details? [OK, Evidence: Spec §Authorization & Access (Groups v1)] + +## Requirement Clarity + +- [x] CHK007 Is “periodic schedule” quantified with an explicit default cadence and configuration surface (per-tenant vs global)? [OK, Evidence: Spec §FR-004b (configurable per environment), Spec §Pinned Decisions (v1 defaults)] +- [x] CHK008 Is “deterministic selection identifier” defined with an unambiguous string/format and stability rules (per tenant + scope versioning)? [OK, Evidence: Spec §FR-004] +- [x] CHK009 Is “deduplicated” behavior specified (e.g., reject/skip when an active run exists; idempotency window), rather than implied? [OK, Evidence: Spec §User Story 1 Acceptance #6, Plan §Idempotency & Concurrency] +- [x] CHK010 Is “stale” defined with a specific comparison rule (e.g., $lastSeenAt < now - N days) and timezone/clock assumptions? [OK, Evidence: Spec §Clarifications (UTC everywhere), Spec §FR-005] +- [x] CHK011 Is the “unresolved fallback” label format and truncation rule specified consistently (e.g., last 8 chars, ellipsis placement)? [OK, Evidence: Spec §User Story 3 Acceptance #1–#2] +- [x] CHK012 Is “no live directory calls during render” defined operationally (what counts as render-time, allowed background refresh vs forbidden UI-triggered calls)? [OK, Evidence: Spec §FR-006a] + +## Requirement Consistency + +- [x] CHK013 Do the Clarifications (app-only auth, metadata-only, full scope) align with the Functional Requirements without contradiction? [OK, Evidence: Spec §Clarifications, Spec §FR-001a, Spec §FR-004a, Spec §FR-011a] +- [x] CHK014 Are the acceptance scenarios for US1 aligned with FR-003 (async only) and FR-002 (run record), without missing a required attribute? [OK, Evidence: Spec §User Story 1 Acceptance, Spec §FR-002, Spec §FR-003] +- [x] CHK015 Do US2 filters (stale) align with FR-005 and FR-007 (what filters are mandatory vs optional)? [OK, Evidence: Spec §User Story 2 Acceptance, Spec §FR-005, Spec §FR-007] +- [x] CHK016 Is the “read-only to Entra” rule consistent across spec (no implied writes or membership reads)? [OK, Evidence: Spec §FR-001, Spec §FR-001a] +- [ ] CHK017 Are success criteria thresholds consistent with scope and constraints (e.g., SC-003 95% resolution vs metadata-only cache + tenant scope)? [Consistency, Spec §SC-003, Spec §FR-001a, Spec §FR-004a] + +## Acceptance Criteria Quality + +- [ ] CHK018 Are acceptance scenarios written with objective outcomes (observable run record fields, counts, timestamps), not subjective terms? [Measurability, Spec §User Story 1 Acceptance] +- [x] CHK019 Do acceptance scenarios specify what constitutes “partial” completion vs “failed” (and expected operator-visible fields)? [OK, Evidence: Spec §FR-002a, Spec §FR-011] +- [x] CHK020 Are the success criteria (SC-001..SC-004) measurable with defined measurement method and scope (which pages, which tenants, sampling window)? [OK, Evidence: Spec §Success Criteria → Measurable Outcomes (SC-001..SC-004)] +- [ ] CHK021 Is the staleness default (N=30) defined as a requirement and stated as configurable (including configuration boundary: env/tenant)? [Measurability, Spec §FR-005] + +## Scenario Coverage + +- [x] CHK022 Are the primary flows covered end-to-end: start sync → run record → cache updated → browse/search → resolve label? [OK, Evidence: Spec §User Story 1–3 Acceptance] +- [x] CHK023 Are scheduled-run scenarios fully defined beyond cadence (initiator identity, operator visibility, and dedupe rules), rather than only mentioned? [OK, Evidence: Spec §Scheduled Sync Semantics (SS-001..SS-003), Spec §User Story 1 Acceptance #5–#7] +- [x] CHK024 Are multi-tenant isolation scenarios explicitly addressed (prevent cross-tenant reads in browse and label resolution)? [OK, Evidence: Spec §FR-010, Spec §Authorization & Access (Groups v1)] +- [x] CHK025 Are “no prior sync” scenarios specified (empty cache UI/labels, guidance to run sync)? [OK, Evidence: Spec §User Story 2 Acceptance #3, Spec §User Story 3 Acceptance #4] + +## Edge Case Coverage + +- [x] CHK026 Are throttling/partial results requirements specified with concrete behavior (retry/backoff, max duration, run status outcome)? [OK, Evidence: Spec §FR-011b, Spec §Edge Cases, Spec §CR-002a, Spec §FR-002a] +- [x] CHK027 Are permission-missing scenarios specified with operator-facing guidance (what the UI shows, what operators should do) beyond a stable error categorization? [OK, Evidence: Spec §FR-011c, Spec §Edge Cases] +- [x] CHK028 Is the behavior specified when a group disappears and later reappears within the 90-day window (last_seen update vs new record)? [OK, Evidence: Spec §FR-005b, Spec §FR-005a] +- [x] CHK029 Is paging/large-tenant behavior specified with bounding rules (max pages, safety stop, counters semantics)? [OK, Evidence: Spec §CR-002a, Spec §FR-002b] + +## Non-Functional Requirements + +- [ ] CHK030 Are performance requirements quantified for large tenants (sync duration, DB query latency, UI list responsiveness)? [Gap, Spec §Edge Cases, Spec §Technical Context] +- [ ] CHK031 Are logging/observability requirements defined (what to log, redaction rules, correlation IDs) for sync runs? [Gap, Spec §FR-009, Spec §FR-011] +- [x] CHK032 Are data minimization and privacy constraints explicitly stated for cached group metadata (e.g., no PII beyond displayName)? [OK, Evidence: Spec §FR-001a, Spec §Pinned Decisions (v1 defaults) Scope boundary] +- [ ] CHK033 Are accessibility requirements specified for the “Directory → Groups” UI (keyboard, table filtering, screen reader labels) if UI is in scope? [Gap, Spec §US2] + +## Dependencies & Assumptions + +- [x] CHK034 Are external dependencies explicitly pinned: required Graph permissions (e.g., Group.Read.All), endpoint family, and version (v1.0/beta)? [OK, Evidence: Spec §Pinned Decisions (v1 defaults), Spec §FR-011a, Spec §Contract Requirements] +- [ ] CHK035 Are Graph contract registry requirements sufficiently specific (contract key/name, allowed selects, expected fields) to be implementable without guesswork? [Completeness, Spec §Contract Requirements] +- [ ] CHK036 Are operational prerequisites specified (queue worker, scheduler/cron, retention job timing) including what happens if they’re missing? [Gap, Spec §Assumptions, Spec §US1 Acceptance] + +## Ambiguities & Conflicts + +- [ ] CHK037 Is the scope of “name resolution across the suite” explicitly bounded (which modules/pages are in v1 vs future)? [Completeness, Spec §Out of Scope (Groups v1)] +- [ ] CHK038 Is the relationship between cached group data and the existing live Graph-based resolution behavior explicitly defined (migration/transition expectations)? [Gap, Spec §FR-006, Spec §FR-008] +- [x] CHK039 Are error categories defined as an explicit controlled vocabulary (permission/throttling/transient/unknown) with mapping criteria? [OK, Evidence: Spec §FR-011] + +## Notes + +- Mark items as complete: `[x]` +- Use inline notes to capture identified gaps and propose spec edits +- This PR gate validates requirements quality (not implementation) diff --git a/specs/051-entra-group-directory-cache/checklists/requirements.md b/specs/051-entra-group-directory-cache/checklists/requirements.md new file mode 100644 index 0000000..3c11095 --- /dev/null +++ b/specs/051-entra-group-directory-cache/checklists/requirements.md @@ -0,0 +1,56 @@ +# Requirements Checklist (Evidence-Based): Entra Group Directory Cache (Groups v1) + +**Purpose**: Implementation gate for Feature 051. Only mark items `[x]` when there is explicit evidence in spec/plan/tasks (or existing repo conventions). Any remaining `[ ]` items must include a concrete follow-up reference (task ID). +**Created**: 2026-01-11 +**Feature**: specs/051-entra-group-directory-cache/spec.md + +## Evidence Sources + +- Spec: specs/051-entra-group-directory-cache/spec.md +- Plan: specs/051-entra-group-directory-cache/plan.md +- Tasks: specs/051-entra-group-directory-cache/tasks.md +- Contracts: specs/051-entra-group-directory-cache/contracts/ +- PR gate (requirements quality): specs/051-entra-group-directory-cache/checklists/pr-gate.md + +## Spec Hardened (Prereq for planning) + +- [x] CHK001 Pinned defaults exist (cadence, auth mode, required permission, paging strategy, staleness/retention). Evidence: spec.md §Pinned Decisions (v1 defaults), spec.md §FR-004b, spec.md §Contract Requirements +- [x] CHK002 Scope boundaries are explicit (no membership/owners, no cross-tenant compare, no delegated tokens required for UI). Evidence: spec.md §Pinned Decisions (v1 defaults), spec.md §Out of Scope (Groups v1), spec.md §FR-001a +- [x] CHK003 Acceptance scenarios include dedupe and “no render-time Graph calls” guard requirement. Evidence: spec.md §User Story 1 Acceptance #6, spec.md §FR-006 + §FR-006a, spec.md §User Story 3 Acceptance #3 + +## Planning Readiness + +- [x] CHK004 Plan removes placeholder “ACTION REQUIRED” sections and contains concrete file paths and sequencing notes. Evidence: plan.md §Project Structure, plan.md §Definition of Done (per phase) +- [x] CHK005 Plan specifies run lifecycle fields + status semantics (pending/running/succeeded/failed/partial) and how counters are computed. Evidence: plan.md §Execution Model → Sync Run Lifecycle +- [x] CHK006 Plan specifies idempotency rule (one active run per tenant+selection) and dedupe window behavior. Evidence: plan.md §Execution Model → Idempotency & Concurrency + +## Contracts & Permissions + +- [x] CHK007 OpenAPI admin surfaces exist for list/detail/sync/runs. Evidence: contracts/admin-directory-groups.openapi.yaml +- [x] CHK008 Graph contract registry entry for groups exists (endpoint + allowed selects). Evidence: config/graph_contracts.php (directoryGroups) +- [x] CHK009 Tenant permission catalog mentions directory-groups feature tagging for Group.Read.All. Evidence: config/intune_permissions.php (Group.Read.All features) + +## Data Model & Retention + +- [x] CHK010 Data model defines EntraGroup + EntraGroupSyncRun, key fields, indexes, and retention rules. Evidence: data-model.md +- [x] CHK011 Migrations exist for groups + runs with tenant scoping and unique constraints. Evidence: database/migrations/2026_01_11_120003_create_entra_groups_table.php + database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php + +## Implementation Tasks Defined + +- [x] CHK012 Task breakdown exists and is grouped by user story with dependencies. Evidence: tasks.md +- [x] CHK013 Feature config exists for staleness/retention/schedule/page_size. Evidence: config/directory_groups.php + +## Test Gate (Pest) + +- [x] CHK014 Tests are explicitly required and enumerated per story (including no-Graph-on-render test). Evidence: tasks.md T010–T013, tasks.md T022, tasks.md T026–T027 +- [x] CHK015 Guard test is implemented to fail hard on Graph client invocation during render. Evidence: tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php + +## Operational Readiness + +- [x] CHK016 Operator workflow documented (manual + scheduled + verification bullets). Evidence: quickstart.md +- [x] CHK017 Scheduled dispatcher command exists and is wired in routes/console.php. Evidence: app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php + routes/console.php + +## Notes + +- This checklist is the implementation gate. Use specs/051-entra-group-directory-cache/checklists/pr-gate.md during PR review to validate *requirements quality*. +- For any unchecked item, keep the follow-up task reference current. diff --git a/specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml b/specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml new file mode 100644 index 0000000..b8063ab --- /dev/null +++ b/specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml @@ -0,0 +1,135 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin - Directory Groups (v1) + version: 0.1.0 + description: | + Admin surfaces for tenant-scoped Entra groups cache (metadata only). + Rendering uses cached data only; sync runs read the directory via app-only auth. + +paths: + /admin/tenants/{tenantId}/directory/groups: + get: + summary: List cached groups + parameters: + - $ref: '#/components/parameters/TenantId' + - in: query + name: q + schema: { type: string } + description: Search by display name or ID + - in: query + name: stale + schema: { type: boolean } + description: Filter to stale groups only + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EntraGroup' + + /admin/tenants/{tenantId}/directory/groups/{entraGroupId}: + get: + summary: Get group details + parameters: + - $ref: '#/components/parameters/TenantId' + - in: path + name: entraGroupId + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EntraGroup' + + /admin/tenants/{tenantId}/directory/groups/sync: + post: + summary: Start a manual groups sync + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '202': + description: Accepted (run created) + content: + application/json: + schema: + $ref: '#/components/schemas/EntraGroupSyncRun' + + /admin/tenants/{tenantId}/directory/groups/runs: + get: + summary: List group sync runs + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EntraGroupSyncRun' + + /admin/tenants/{tenantId}/directory/groups/runs/{runId}: + get: + summary: Get group sync run details + parameters: + - $ref: '#/components/parameters/TenantId' + - in: path + name: runId + required: true + schema: { type: integer } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EntraGroupSyncRun' + +components: + parameters: + TenantId: + in: path + name: tenantId + required: true + schema: { type: integer } + + schemas: + EntraGroup: + type: object + required: [tenant_id, entra_group_id, display_name, last_seen_at] + properties: + tenant_id: { type: integer } + entra_group_id: { type: string } + display_name: { type: string } + group_type: { type: string } + security_enabled: { type: boolean, nullable: true } + mail_enabled: { type: boolean, nullable: true } + group_types: + type: array + items: { type: string } + last_seen_at: { type: string, format: date-time } + + EntraGroupSyncRun: + type: object + required: [tenant_id, selection_key, status, created_at] + properties: + id: { type: integer } + tenant_id: { type: integer } + initiated_by_user_id: { type: integer, nullable: true } + selection_key: { type: string } + status: { type: string } + started_at: { type: string, format: date-time, nullable: true } + finished_at: { type: string, format: date-time, nullable: true } + observed_count: { type: integer, nullable: true } + upserted_count: { type: integer, nullable: true } + error_count: { type: integer, nullable: true } + error_category: { type: string, nullable: true } + error_summary: { type: string, nullable: true } + created_at: { type: string, format: date-time } diff --git a/specs/051-entra-group-directory-cache/data-model.md b/specs/051-entra-group-directory-cache/data-model.md new file mode 100644 index 0000000..47259d2 --- /dev/null +++ b/specs/051-entra-group-directory-cache/data-model.md @@ -0,0 +1,56 @@ +# Data Model: Entra Group Directory Cache (Groups v1) + +## Entities + +### EntraGroup +Represents a cached directory group for a single tenant (metadata only). + +**Fields (conceptual)** +- `id` (internal) +- `tenant_id` (FK) +- `entra_group_id` (GUID/string; external stable identifier) +- `display_name` (string) +- `group_type` (enum/string, derived; e.g., `Security`, `Microsoft365`, `Unknown`) +- `security_enabled` (bool, nullable) +- `mail_enabled` (bool, nullable) +- `group_types` (array/JSONB, nullable) +- `last_seen_at` (timestamp) +- `last_seen_run_id` (FK to sync run, optional) +- `created_at`, `updated_at` + +**Constraints & indexes** +- Unique: (`tenant_id`, `entra_group_id`) +- Index: (`tenant_id`, `display_name`) for search +- Index: (`tenant_id`, `last_seen_at`) for stale/retention filtering + +### EntraGroupSyncRun +Append-only record for one sync attempt. + +**Fields (conceptual)** +- `id` +- `tenant_id` (FK) +- `initiated_by_user_id` (FK nullable for scheduled) +- `selection_key` (string; deterministic for Groups v1 per tenant) +- `status` (enum: `pending`, `running`, `succeeded`, `failed`) +- `started_at`, `finished_at` +- counters: `observed_count`, `upserted_count`, `error_count` +- `error_category` (string/enum; e.g., `permission`, `throttling`, `transient`, `unknown`) +- `error_summary` (safe string) +- `created_at`, `updated_at` + +**Constraints & indexes** +- Index: (`tenant_id`, `created_at`) +- Index: (`tenant_id`, `status`) +- Optional unique/idempotency: (`tenant_id`, `selection_key`, `status in active`) in code using locks. + +## Relationships +- `Tenant` 1 → N `EntraGroup` +- `Tenant` 1 → N `EntraGroupSyncRun` +- `EntraGroupSyncRun` 1 → N `EntraGroup` (via `last_seen_run_id`, optional) + +## State transitions +- `pending` → `running` → `succeeded` | `failed` + +## Retention +- Purge rule: delete `EntraGroup` where `last_seen_at` < now - 90 days. +- Stale classification: stale if `last_seen_at` < now - N days (default N = 30). diff --git a/specs/051-entra-group-directory-cache/plan.md b/specs/051-entra-group-directory-cache/plan.md new file mode 100644 index 0000000..055513d --- /dev/null +++ b/specs/051-entra-group-directory-cache/plan.md @@ -0,0 +1,141 @@ +# Implementation Plan: Entra Group Directory Cache (Groups v1) + +**Branch**: `051-entra-group-directory-cache` | **Date**: 2026-01-11 | **Spec**: [specs/051-entra-group-directory-cache/spec.md](spec.md) +**Input**: Feature specification from `/specs/051-entra-group-directory-cache/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Provide a tenant-scoped Entra ID Groups metadata cache (no membership/owners) populated by queued sync runs (manual + scheduled), so the admin UI and other modules can resolve group IDs to friendly names without making live directory calls during page render. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v4, Livewire v3, Microsoft Graph integration via internal `GraphClientInterface` +**Storage**: PostgreSQL +**Testing**: Pest v4 +**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy) +**Project Type**: web +**Performance Goals**: background sync handles large tenants via paging; UI list/search remains responsive with DB indexes +**Constraints**: no live directory calls during page render; app-only Graph auth; safe error summaries; strict tenant isolation; idempotent runs; retention purge after 90 days last-seen +**Scale/Scope**: full-tenant group enumeration; plan for up to ~100k groups/tenant for v1 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: cache is “last observed” directory state; no snapshot payloads. +- Read/write separation: read-only to the directory; only internal DB writes. +- Graph contract path: directory group list endpoint/fields/permissions must be added to `config/graph_contracts.php` and accessed via `GraphClientInterface`. +- Deterministic capabilities: selection identifier for groups sync is deterministic (stable per tenant + groups-v1). +- Tenant isolation: all cached groups + run records tenant-scoped; no cross-tenant access paths. +- Automation: manual + scheduled sync runs use locks/idempotency, run records, safe error codes; handle 429/503 with backoff+jitter. +- Data minimization: store group metadata only (no membership/owners); logs contain no secrets/tokens. + +**Gate status (pre-Phase 0)**: PASS (no violations). Re-check after Phase 1 design. + +**Gate status (post-Phase 1)**: PASS (design artifacts present: research.md, data-model.md, contracts/*, quickstart.md). + +## Project Structure + +### Documentation (this feature) + +```text +specs/051-entra-group-directory-cache/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) +```text +app/ +├── Filament/ +│ ├── Pages/ +│ └── Resources/ +├── Jobs/ +├── Models/ +├── Services/ +│ ├── Graph/ +│ └── Directory/ +└── Support/ + +config/ +├── graph.php +└── graph_contracts.php + +database/ +└── migrations/ + +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel web application. Implement directory group sync + cache as tenant-scoped Models, Services, Jobs, Filament pages/resources, migrations, and Pest tests. + +## Key Implementation Decisions (Pinned) + +- **Schedule default**: daily at **02:00 UTC** (environment default) + manual sync always available. +- **Auth mode**: app-only (service principal). UI must not require delegated tokens. +- **Permission**: `Group.Read.All` (application). +- **Graph strategy (v1)**: `GET /groups` with `$select=id,displayName,groupTypes,securityEnabled,mailEnabled` + paging via `@odata.nextLink`. +- **Retention**: stale threshold default 30 days; purge after 90 days since `last_seen_at`. +- **Render safety**: fail-hard test ensuring no Graph client calls during render. + +## Execution Model + +### Sync Run Lifecycle + +- Run statuses: `pending` → `running` → `succeeded` | `failed` (optionally `partial` if we intentionally record partial completion). +- Each run records: initiator (nullable for scheduled), timestamps, observed/upserted/error counters, and a safe error summary + category. + +### Idempotency & Concurrency + +- Deterministic selection key for v1: `groups-v1:all`. +- Deduplication rule: at most one active run per tenant + selection key. +- Scheduled dispatcher must be idempotent per tenant and schedule slot (no duplicate run creation). + +### Error Categorization + +- Supported categories: `permission`, `throttling`, `transient`, `unknown`. +- Store only safe-to-display summaries (no secrets/tokens). + +## Definition of Done (per phase) + +### Phase 2 (Foundational) + +- Migrations created for EntraGroup and EntraGroupSyncRun. +- Models + factories exist and are tenant-scoped. +- Graph contract registry has a groups directory read contract. + +### Phase 3 (US1) + +- Manual sync creates a run and dispatches a job. +- Job pages through Graph, upserts groups, updates counters, and enforces retention purge. +- Filament run list/detail exists; scheduler dispatch is wired. +- Pest tests for run creation, upsert behavior, retention purge, and scheduled run dispatch are green. + +### Phase 4 (US2) + +- Directory → Groups list/detail exists with search + stale filter + type filter. +- All browsing renders from DB cache only. + +### Phase 5 (US3) + +- Shared label resolver resolves from DB cache and provides consistent fallback formatting. +- Key pages render friendly labels without Graph calls. +- Fail-hard render guard test is green. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +N/A (no constitution violations anticipated) diff --git a/specs/051-entra-group-directory-cache/quickstart.md b/specs/051-entra-group-directory-cache/quickstart.md new file mode 100644 index 0000000..e00e5c6 --- /dev/null +++ b/specs/051-entra-group-directory-cache/quickstart.md @@ -0,0 +1,42 @@ +# Quickstart: Entra Group Directory Cache (Groups v1) + +## Goal +Populate a tenant-scoped cache of Entra ID groups (metadata only) via background sync runs, enabling safe UI rendering without live directory calls. + +## Prerequisites +- Graph integration configured (client credentials / app-only) +- Database migrated (feature migrations applied) +- Queue worker running for jobs (required for sync) +- Scheduler/cron running for periodic sync (optional, if enabled) + +### Local (Sail) quick commands + +- Bring containers up: `./vendor/bin/sail up -d` +- Run migrations: `./vendor/bin/sail artisan migrate` +- Run the queue worker (separate terminal): `./vendor/bin/sail artisan queue:work` +- Run the scheduler loop (separate terminal, optional): `./vendor/bin/sail artisan schedule:work` + +## Operator workflow (manual) +1. Switch into a tenant workspace. +2. Go to **Directory → Group Sync Runs**. +3. Click **Sync Groups** (creates/reuses a run and dispatches a background job). +4. Open the run and wait for status to complete. +5. Go to **Directory → Groups** and confirm list/search/detail are populated from the cache. + +Notes +- Search is cached-only and typically requires at least 2 characters. +- If the cache is empty, the Groups list will be empty until a sync completes. + +## Scheduled sync +- Ensure the scheduler is running (cron or `schedule:work`). +- Verify **Directory → Group Sync Runs** shows runs with empty Initiator (scheduled). +- The dispatcher command is `tenantpilot:directory-groups:dispatch` (configured to run every minute). + +## Verification +- UI pages that show group IDs render using cached data only. +- Unresolved IDs show a clear fallback. +- Groups not seen for >90 days are eventually purged. + +Suggested validation checks +- `./vendor/bin/sail artisan migrate:status | grep entra_group` +- `./vendor/bin/sail artisan schedule:list | grep directory-groups` diff --git a/specs/051-entra-group-directory-cache/research.md b/specs/051-entra-group-directory-cache/research.md new file mode 100644 index 0000000..c626673 --- /dev/null +++ b/specs/051-entra-group-directory-cache/research.md @@ -0,0 +1,42 @@ +# Research: Entra Group Directory Cache (Groups v1) + +## Decisions + +### Decision: Full tenant scope sync (all groups) +- Rationale: Ensures browse/search is complete and name-resolution has the best chance of resolving IDs across modules. +- Alternatives considered: + - Only sync referenced groups (creates gaps, hard to debug, non-deterministic coverage). + +### Decision: Start modes = manual + scheduled +- Rationale: Manual sync supports operator workflows (pre-restore/triage), while scheduled sync keeps cache fresh without relying on user action. +- Alternatives considered: + - Manual only (cache freshness depends on operator discipline). + - Scheduled only (harder to react quickly during ops). + +### Decision: App-only (service principal) directory reads +- Rationale: Scheduled runs must not depend on an interactive user session; app-only is simpler to audit/lock down. +- Alternatives considered: + - Delegated tokens (breaks scheduled runs, unpredictable auth state). + +### Decision: Cache metadata only (no membership/owners) +- Rationale: Solves the UX problem (name resolution + browse) with minimal data/PII exposure and lower API volume. +- Alternatives considered: + - Cache membership/owners (larger data surface, higher sensitivity, more Graph calls). + +### Decision: Missing groups retention and purge +- Decision: Retain groups for 90 days after `last_seen_at`, then purge. +- Rationale: Preserves investigatory value and audit context while bounding DB growth. +- Alternatives considered: + - Immediate delete (breaks historical triage; creates sudden “unresolved” labels). + - Retain forever (unbounded growth). + +### Decision: Graph access patterns (paging + retry) +- Decision: Use Graph list endpoint with paging; apply retry/backoff for 429/503; persist stable error categories in run record. +- Rationale: Full-tenant enumeration can be large; throttling is expected. +- Alternatives considered: + - Best-effort without retry (unreliable coverage). + - UI-time lookups (explicitly disallowed by spec). + +## Open Questions (resolved for planning) + +- Which specific Graph endpoint and permission names to use will be documented in the contract registry during implementation. diff --git a/specs/051-entra-group-directory-cache/spec.md b/specs/051-entra-group-directory-cache/spec.md new file mode 100644 index 0000000..04315e9 --- /dev/null +++ b/specs/051-entra-group-directory-cache/spec.md @@ -0,0 +1,236 @@ +# Feature Specification: Entra Group Directory Cache (Groups v1) + +**Feature Branch**: `051-entra-group-directory-cache` +**Created**: 2026-01-11 +**Status**: Draft +**Input**: User description: "Tenant-scoped Entra ID Groups cache (read-only), populated by queued sync runs, used for name-resolution across the suite; UI must render from DB-only (no live directory calls)." + +## Clarifications + +### Session 2026-01-11 + +- Q: What is the scope of the Groups v1 sync source? → A: All groups in the tenant. +- Q: How is the Groups sync started (MVP)? → A: Manual + scheduled/periodic. +- Q: What happens in the cache when a group is not returned on the next full sync? → A: Retain for 90 days after last seen, then purge. +- Q: In which auth mode does TenantAtlas read groups for sync runs? → A: App-only / service principal. +- Q: What data is cached in Groups v1? → A: Group metadata only (no membership/owners). +- Q: What timezone/clock semantics apply for staleness/retention comparisons? → A: UTC everywhere. +- Q: How are sync run statuses defined (partial vs failed)? → A: Partial only if some pages processed and at least one upsert occurred; otherwise failed. +- Q: What paging “safety stop” bounds apply to full-tenant group enumeration (v1)? → A: Max 200 pages, max 10 minutes, abort on retry exhaustion; record safety-stop reason + counters. + +## Pinned Decisions (v1 defaults) + +These defaults are intentionally “hard” for Groups v1 to avoid interpretability during planning/implementation. + +- **Schedule cadence default**: Scheduled sync runs daily at **02:00 UTC** (environment default). Manual sync is always available. +- **Auth mode (required)**: App-only (service principal). UI MUST NOT require delegated tokens. +- **Required Graph permission (application)**: `Group.Read.All`. +- **Graph API family**: Must work with Microsoft Graph **v1.0** semantics (no beta-only features required). +- **Paging strategy (v1)**: Full listing with `@odata.nextLink` paging. Delta sync is explicitly deferred to a future version. +- **Staleness default**: A group is “stale” if `last_seen_at < now() - 30 days` (computed in **UTC**, configurable per environment). +- **Retention/purge default**: Retain unseen groups for **90 days after last_seen_at** (computed in **UTC**), then purge. +- **Scope boundary**: No group membership/owners caching; no cross-tenant compare/promotion work inside this feature. + +## Authorization & Access (Groups v1) + +TenantPilot roles are tenant-scoped. Unless stated otherwise, all access below is limited to the active tenant context. + +- **Browse cached groups (Directory → Groups)**: allowed for roles `Owner`, `Manager`, `Operator`, `Readonly`. +- **View group sync runs**: allowed for roles `Owner`, `Manager`, `Operator`, `Readonly`. +- **Start manual sync**: allowed for roles `Owner`, `Manager`, `Operator`. +- **Cross-tenant access**: forbidden (no browsing or resolution of another tenant’s cached groups). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Sync groups into a tenant-scoped cache (Priority: P1) + +As a tenant admin, I can trigger a background sync that stores the latest observed Entra groups into a tenant-scoped cache, so the suite can display stable, human-friendly group names without relying on live directory lookups. + +**Why this priority**: Without a cache, most assignment-heavy workflows become hard to use and hard to troubleshoot (only group IDs). + +**Independent Test**: Trigger a sync for a tenant and verify that a run record exists and that group rows become available for that tenant. + +**Acceptance Scenarios**: + +1. **Given** I am in a tenant workspace, **When** I start a “Sync Groups” operation, **Then** a run is created and the sync executes asynchronously (request returns without waiting). +2. **Given** I start a groups sync for a tenant, **When** the sync completes successfully, **Then** the cache reflects the full set of groups in that tenant as of that run. +3. **Given** a sync run completes successfully, **When** I browse groups for that tenant, **Then** I see group entries with display names and stable identifiers. +4. **Given** a sync run completes partially or fails, **When** I view the run record, **Then** I can see status and a safe error summary that helps triage. +5. **Given** scheduled sync is enabled, **When** the schedule triggers, **Then** a run is created and executed without manual intervention and is visible to operators. +6. **Given** a groups sync is already running for the same tenant and selection, **When** I start another sync, **Then** the request is deduplicated (no second concurrent run is created) and I can identify the already-active run. +7. **Given** a scheduled sync run is created, **When** I view the run record, **Then** it is clearly identified as “scheduled/system-initiated” (no interactive user session required). + +--- + +### User Story 2 - Browse groups (Priority: P2) + +As a tenant admin, I can browse, search, and filter cached groups so I can quickly resolve group IDs to names and validate whether expected groups exist in the tenant. + +**Why this priority**: Operators need a direct “source of truth (as last seen)” surface to debug restore mapping, dependencies, and drift findings. + +**Independent Test**: After a sync run, open the groups list and verify search/filter/detail views work using only cached data. + +**Acceptance Scenarios**: + +1. **Given** groups have been synced, **When** I open “Directory → Groups”, **Then** I can search by display name and open a group detail view. +2. **Given** some groups were not observed recently, **When** I filter for “stale” groups, **Then** I see only groups whose last-seen timestamp is older than the staleness threshold. +3. **Given** no groups have been synced yet, **When** I open “Directory → Groups”, **Then** I see a clear empty-state that explains the cache is empty and offers a “Sync Groups” action. + +--- + +### User Story 3 - Name resolution across the suite (Priority: P3) + +As an operator, when other pages reference group IDs (dependencies, restore mapping, drift, compare), the UI shows a friendly label if the group exists in the cache; otherwise it shows a clear “unresolved” fallback. + +**Why this priority**: This turns the cache into a foundational building block used across modules while keeping rendering safe and predictable. + +**Independent Test**: Load a page that includes a group GUID reference and verify it renders with a name if present, and with a fallback if not present—without making any live directory calls during rendering. + +**Acceptance Scenarios**: + +1. **Given** a page references a group GUID that exists in the cache, **When** I view the page, **Then** I see `Group: (…last8)` (or equivalent) derived from cached data. +2. **Given** a page references a group GUID that does not exist in the cache, **When** I view the page, **Then** I see `Group (unresolved): …last8` (or equivalent) and the page still renders. +3. **Given** I view any page that renders group labels, **When** the page renders, **Then** it MUST NOT make any live directory calls (no Graph requests during render-time), and automated tests MUST fail hard if a Graph client is invoked. +4. **Given** no groups have been synced yet, **When** a page renders a group GUID reference, **Then** it still renders with the unresolved fallback (and does not attempt live directory lookup during render). + +--- + +### Edge Cases + +- **Throttling / transient failures (retry policy)**: + - Retryable conditions for Graph reads: HTTP `429`, `503`, and network timeouts. + - Non-retryable (fail-fast): HTTP `403` (permission), and other non-2xx responses unless explicitly categorized as retryable. + - Backoff strategy: exponential backoff with jitter (full jitter), capped. + - Max retries (total per run): 8 (aligned with safety stop `retry_exhausted`). + - If retries are exhausted: run MUST abort with `safety_stop_triggered=true`, `safety_stop_reason=retry_exhausted`, and status MUST follow FR-002a / CR-002a (partial if any upserts, else failed). + - Operator-visible: run record MUST show `error_category=throttling` (or `transient` for timeouts), and a safe summary including retry count and last HTTP status (if available). + +- **Permission missing (forbidden)**: + - If Graph returns HTTP `403` on group list, the run MUST stop immediately (no retries), with `error_category=permission`, and status MUST be `failed`. + - Operator-visible guidance MUST be explicit: `Group.Read.All` (application permission) missing and/or admin consent missing. + +- **Groups disappear / reappear**: + - If a group is not observed, it is retained per FR-005a and may still resolve labels until purged. + - If the same group ID reappears within the retention window, the record MUST be updated and `last_seen_at` refreshed. + +- **Large tenants**: + - Runs MUST respect CR-002a safety-stop bounds (max pages / max runtime) to prevent runaway cost and load. + - UI browse/search MUST operate on cached DB data only and rely on indexes (no live Graph during render-time). + +## Out of Scope (Groups v1) + +- Caching group **membership** or **owners**. +- Any UI behavior that requires delegated Graph tokens for group name resolution. +- Cross-tenant compare/promotion of groups. +- Delta-sync based directory change tracking. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any external directory calls or any write/change behavior, +the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests. + +### Assumptions & Dependencies + +- The application already has a tenant context concept; this cache is scoped strictly to the active tenant. +- Background processing infrastructure exists (queue worker) so sync can run asynchronously. +- The tenant has (or can be granted) sufficient directory read permissions to list groups. +- Directory reads for group sync run using app-only (service principal) permissions. +- Required Graph permission for Groups v1 is `Group.Read.All` (application permission). +- Groups v1 does not require Graph beta-only capabilities. +- Other modules that display group references can integrate via a shared “group name resolution” capability. +- Groups v1 sync scope is all groups in the tenant (not only groups already referenced by TenantAtlas). +- The system can execute scheduled background work (e.g., cron/scheduler) to run periodic group sync. +- The system can enforce retention/purge for cached groups that have not been observed for a configured period. +- Groups v1 cache stores group metadata only and does not store group membership or owners. + +### Functional Requirements + +- **FR-001 (Tenant-scoped cache)**: System MUST store a tenant-scoped cache of Entra groups in the application, and it MUST be read-only with respect to Entra. +- **FR-001a (Cached fields; no membership caching)**: Groups v1 caching MUST store only group metadata needed for name resolution and browsing, and MUST NOT store group membership or owners. Cached fields for v1 MUST include: `id`, `displayName`, `groupTypes`, `securityEnabled`, `mailEnabled`, and `last_seen_at`. +- **FR-002 (Sync runs)**: System MUST create an append-only “sync run” record for each sync attempt, including lifecycle status and basic counters (observed/upserted/errors). +- **FR-002a (Status semantics: partial vs failed)**: The run MUST use explicit statuses: `pending`, `running`, `succeeded`, `failed`, `partial`. Criteria: + - `succeeded`: all pages processed to completion. + - `partial`: at least one page was processed AND `upserted_count > 0`, but the run did not complete successfully (e.g., aborted due to repeated throttling, transient faults, or other non-fatal error conditions). + - `failed`: zero progress (no processed pages) OR `upserted_count = 0` due to a fatal condition (e.g., missing permission) or immediate abort. + The run record MUST include `error_category` (see FR-011) and a safe `error_summary` when status is `failed` or `partial`. +- **FR-002b (Run record: paging + safety stop fields)**: The run record MUST include `pages_fetched`, `items_observed_count`, `items_upserted_count`, and (when applicable) `safety_stop_triggered` plus `safety_stop_reason`. +- **FR-003 (Async only)**: Starting a groups sync MUST dispatch background work and MUST NOT perform full sync work in the initiating HTTP request. +- **FR-004 (Idempotent selection)**: System MUST support a deterministic “selection identifier” for the v1 groups sync scope so repeated requests with the same selection can be recognized and deduplicated. For Groups v1, the selection identifier MUST be the stable string `groups-v1:all`. +- **FR-004a (Full tenant scope)**: For Groups v1, the sync MUST attempt to enumerate all groups visible in the tenant scope (subject to permissions and provider limits). +- **FR-004b (Start modes + cadence default)**: The system MUST support starting groups sync both manually (operator-initiated) and on a periodic schedule. The default scheduled cadence for Groups v1 MUST be daily at 02:00 UTC (configurable per environment). +- **FR-005 (Staleness)**: System MUST track when a group was last observed and MUST allow identifying “stale” groups as “not observed for N days”; for v1, default N = 30 and configurable per environment. +- **FR-005 (Staleness)**: System MUST track when a group was last observed and MUST allow identifying “stale” groups as “not observed for N days”; for v1, default N = 30 and configurable per environment. All comparisons MUST be computed in **UTC**. +- **FR-005a (Retention & purge)**: If a group is not observed in subsequent full-tenant syncs, the system MUST retain the cached record for 90 days after its last observed timestamp (`last_seen_at`), and it MUST purge the record after that retention window. All comparisons MUST be computed in **UTC**. +- **FR-005b (Disappear / reappear semantics)**: The cache MUST behave deterministically when a group disappears and later reappears: + - If a group is not observed in a run, its cached row MUST remain unchanged (including `last_seen_at`) until either it is observed again or it is purged per FR-005a. + - If the same group `id` is observed again **within** the retention window, the system MUST update the existing row (refresh `displayName`, flags/types as returned, and set `last_seen_at` to the run’s observation time). + - If the group was already purged (retention window elapsed) and later reappears, the system MUST create a new cached row for that group `id` on observation. +- **FR-006 (UI safety + guard test)**: UI rendering for directory groups and for name resolution MUST use cached data only (no live directory calls at render time). The feature MUST include a test that fails hard if the Graph client is invoked during render. +- **FR-006a (Definition: render-time)**: “Render-time” means the synchronous request lifecycle that produces UI output (Filament pages, Livewire component renders, and any server-side code executed to build the response). During render-time, the system MUST NOT call Microsoft Graph for group data. Background work (queued jobs, scheduled commands) MAY call Graph. +- **FR-007 (Search & filters)**: Users MUST be able to search cached groups by name and filter by at least “stale vs. fresh” and “group type” (security vs. M365 group) when that info is available. +- **FR-008 (Cross-module resolution)**: When other modules reference a group ID, the system MUST resolve it to a friendly label from the cache when available, and MUST show a clear unresolved fallback when not available. +- **FR-009 (Audit & observability)**: Starting a sync and completing/failing a sync MUST be auditable (who initiated, when, status), and the operator MUST be able to view the run record. +- **FR-009a (Audit minimum fields + visibility)**: Each sync attempt MUST produce (1) a sync run record visible in the admin UI, and (2) audit entries for start and finish/failure visible in the tenant audit log. Minimum audit metadata for these entries: `tenant`, `action`, `status`, `run_id`, `selection_key`, initiator identity (user id or “system”), and on completion: `observed_count`, `upserted_count`, `error_count`, `error_category` (if any). +- **FR-010 (Tenant isolation)**: All group cache data and sync run data MUST be strictly tenant-scoped; cross-tenant access MUST be prevented by authorization. +- **FR-011 (Error hygiene + categories)**: Failure details stored for runs MUST be safe to display (no secrets), and SHOULD be summarized into stable categories. For v1, the supported categories are: `permission`, `throttling`, `transient`, `unknown`. +- **FR-011b (Retry & backoff policy; v1)**: The sync implementation MUST apply a consistent retry policy for Graph group listing: + - Retryable: HTTP `429`, `503`, and network timeouts. + - Backoff: exponential backoff with jitter (full jitter), capped at 60 seconds per delay. + - Max retries: 8 total per run. + - On retry exhaustion: abort per CR-002a (`retry_exhausted`) and set status per FR-002a. +- **FR-011c (Permission-missing operator UX)**: When a run fails due to missing permissions (HTTP `403`), the operator-facing UI MUST display a stable error code and guidance. + - Error code: `graph_forbidden`. + - Guidance: “Grant `Group.Read.All` (application permission) and admin consent for the tenant, then retry.” +- **FR-011d (Throttling/transient operator UX)**: When a run aborts due to throttling/transient retry exhaustion, the operator-facing UI MUST display a stable warning/error code and a safe summary. + - Error/warning code: `graph_throttled` for repeated `429/503`; `graph_timeout` for timeouts. + - Summary MUST include: retry count (up to 8) and whether the safety stop was triggered. +- **FR-011a (Auth mode + required permission)**: Directory reads for groups sync MUST use app-only (service principal) authorization and MUST NOT depend on an interactive user session. The required Graph permission is `Group.Read.All` (application). +- **FR-012 (Tests)**: The feature MUST include automated tests covering tenant isolation, basic sync-run lifecycle persistence, and “UI pages render without live directory calls,” including a fail-hard guard test that asserts the Graph client is not invoked during render. + +### Scheduled Sync Semantics + +- **SS-001 (Initiator identity)**: Scheduled sync runs MUST be recorded as system-initiated (no user initiator). +- **SS-002 (Visibility)**: Operators MUST be able to distinguish scheduled vs manual runs when viewing run records. +- **SS-003 (Schedule dedupe)**: Scheduled dispatch MUST NOT create duplicate runs for the same tenant + selection in the same schedule slot. If a run for the current slot already exists (or an active run is in progress), the dispatcher MUST skip creating a second run. + +### Contract Requirements + +- **CR-001 (Graph contract registry)**: The feature MUST register the Groups v1 directory read contract in the Graph contract registry. +- **CR-002 (List endpoint + select fields)**: Sync MUST read groups via `GET /groups` with `$select=id,displayName,groupTypes,securityEnabled,mailEnabled` and MUST page via `@odata.nextLink` until completion (or a documented safety stop). +- **CR-002a (Safety stop: bounds + abort criteria)**: Sync MUST enforce safety-stop bounds for Groups v1 to prevent runaway runs: + - **Max pages**: 200 pages per run. + - **Max runtime**: 10 minutes per run. + - **Abort criteria (immediate)**: stop the run if runtime exceeds max runtime, pages exceed max pages, or the Graph client exceeds 8 total retries for retryable throttling/transient conditions (e.g., repeated 429/503) (“retry exhausted”). + - **Abort status**: if `items_upserted_count > 0`, mark run as `partial`; otherwise mark run as `failed`. + - **Run record**: set `safety_stop_triggered=true` and set `safety_stop_reason` to one of: `max_pages`, `max_runtime`, `retry_exhausted`. +- **CR-003 (Delta strategy deferred)**: Delta endpoints/strategies MUST NOT be used in Groups v1. + +### Key Entities *(include if feature involves data)* + +- **EntraGroup**: A tenant-scoped cached record representing an Entra group (external ID, display name, group type/flags when available, last observed timestamp). Groups v1 stores metadata only. +- **EntraGroupSyncRun**: A tenant-scoped run record representing one attempt to sync groups (status, timestamps, counters, safe error summary, initiator). +- **GroupReference**: A cross-module reference to a group by ID that can be resolved (or not) via the cache. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (Resolve time)**: For a tenant with cached groups, operators can resolve a group ID to a human-friendly label in under 30 seconds. + - **Measured from**: UI workflow time (manual stopwatch) for either (a) Directory → Groups search + open detail OR (b) in-context label rendering. + - **Scope/window**: 95th percentile over 20 attempts on a representative tenant with cached groups. + - **Pass/fail reporting**: recorded as a QA note for the release gate. + +- **SC-002 (Render resilience)**: Directory/Groups pages render successfully even when the external directory API is unavailable. + - **Measured from**: server-side request completion (HTTP 200) while the sync job’s Graph calls are failing/blocked (e.g., simulated outage), demonstrating the UI does not depend on live Graph during render-time. + - **Scope/window**: 20 page loads across Directory → Groups list and detail. + - **Pass/fail reporting**: QA note; must remain true for all future pages integrating the label resolver. + +- **SC-003 (Label resolution rate)**: After a successful sync, at least 95% of group GUID references on supported pages resolve to a friendly label. + - **Measured from**: page output inspection against the cached DB state (resolved label present vs unresolved fallback) for a representative tenant. + - **Scope/window**: sample of supported pages + a set of group GUID references observed in the last successful run; target is 95% resolved. + - **Pass/fail reporting**: QA note with sample size and tenant used. + +- **SC-004 (End-to-end operator workflow time)**: Operators can complete “Sync Groups → Verify group exists → Use group in mapping” in under 3 minutes. + - **Measured from**: UI workflow time (manual stopwatch) plus sync run duration from DB. + - **Scope/window**: 95th percentile over the last 20 runs per tenant + selection key (`groups-v1:all`). + - **Reporting requirement**: run detail UI MUST show `started_at`, `finished_at`, computed `duration_seconds`, and counters (`items_observed_count`, `items_upserted_count`, `error_count`). diff --git a/specs/051-entra-group-directory-cache/tasks.md b/specs/051-entra-group-directory-cache/tasks.md new file mode 100644 index 0000000..cc928f1 --- /dev/null +++ b/specs/051-entra-group-directory-cache/tasks.md @@ -0,0 +1,184 @@ +--- + +description: "Task list for implementing Entra Group Directory Cache (Groups v1)" +--- + +# Tasks: Entra Group Directory Cache (Groups v1) + +**Input**: Design documents from `/specs/051-entra-group-directory-cache/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Required (Pest), per FR-012 in `spec.md`. + +**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently. + +## Format: `- [ ] T### [P?] [US#?] Description (with file path)` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[US#]**: User story mapping (US1/US2/US3) + +## Path Conventions (Laravel) + +- App code: `app/` +- Config: `config/` +- DB: `database/migrations/`, `database/factories/` +- Filament admin: `app/Filament/Resources/` +- Console + scheduler wiring: `app/Console/Commands/`, `routes/console.php` +- Tests (Pest): `tests/Feature/`, `tests/Unit/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Introduce feature configuration and permission metadata. + +- [x] T001 Create feature config defaults in config/directory_groups.php (staleness_days=30, retention_days=90, schedule enabled/interval, page_size) +- [x] T002 Update Group.Read.All feature tagging in config/intune_permissions.php (include directory-groups / group-directory-cache) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Database schema + core domain objects required by all user stories. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T003 Create migrations for Entra groups + sync runs in database/migrations/*_create_entra_groups_table.php and database/migrations/*_create_entra_group_sync_runs_table.php +- [x] T004 [P] Create EntraGroup model in app/Models/EntraGroup.php (tenant-scoped, casts for group_types) +- [x] T005 [P] Create EntraGroupSyncRun model in app/Models/EntraGroupSyncRun.php (status constants, counters, safe error fields) +- [x] T006 [P] Create EntraGroup factory in database/factories/EntraGroupFactory.php +- [x] T007 [P] Create EntraGroupSyncRun factory in database/factories/EntraGroupSyncRunFactory.php +- [x] T008 Add tenant relationships in app/Models/Tenant.php (entraGroups(), entraGroupSyncRuns()) +- [x] T009 Add directory-groups contract metadata in config/graph_contracts.php and accessor(s) in app/Services/Graph/GraphContractRegistry.php (select fields + base path for /groups) + +**Checkpoint**: Foundation ready — user stories can now proceed. + +--- + +## Phase 3: User Story 1 - Sync groups into a tenant-scoped cache (Priority: P1) 🎯 MVP + +**Goal**: Manual + scheduled async sync writes tenant-scoped group cache and run records (including safe error summaries). + +**Independent Test**: Trigger a sync for a tenant and verify a run record exists and group rows are populated for that tenant. + +### Tests for User Story 1 (REQUIRED) ⚠️ + +> Write these tests FIRST and ensure they fail before implementation. + +- [x] T010 [P] [US1] Add test for manual sync creating a run + dispatching a job in tests/Feature/DirectoryGroups/StartSyncTest.php +- [x] T011 [P] [US1] Add test that sync job upserts groups + updates counters in tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +- [x] T012 [P] [US1] Add test that retention purge deletes groups older than retention window in tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +- [x] T013 [P] [US1] Add test that scheduled dispatcher creates a run without a user initiator in tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php + +- [x] T014 [P] [US1] Implement deterministic selection key helper in app/Services/Directory/EntraGroupSelection.php +- [x] T015 [US1] Implement sync service in app/Services/Directory/EntraGroupSyncService.php (Graph paging via GraphClientInterface, upsert, counters, retention purge) +- [x] T016 [US1] Implement queued job in app/Jobs/EntraGroupSyncJob.php (run lifecycle: pending→running→succeeded/failed; safe error_category + error_summary) +- [x] T017 [US1] Add audit logging for sync start/completion in app/Jobs/EntraGroupSyncJob.php using app/Services/Intune/AuditLogger.php +- [x] T018 [P] [US1] Create Filament run resource in app/Filament/Resources/EntraGroupSyncRunResource.php and app/Filament/Resources/EntraGroupSyncRunResource/Pages/{ListEntraGroupSyncRuns,ViewEntraGroupSyncRun}.php +- [x] T019 [US1] Add “Sync Groups” action on List page in app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php (creates run, dispatches job, shows notification) +- [x] T020 [US1] Implement scheduled dispatcher command in app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php (idempotent per tenant + minute-slot, respects config/directory_groups.php) +- [x] T021 [US1] Register scheduler entry in routes/console.php for tenantpilot:directory-groups:dispatch (every minute) + +**Checkpoint**: US1 complete — cache population works, runs are visible, and scheduled runs can be observed. + +--- + +## Phase 4: User Story 2 - Browse groups (Priority: P2) + +**Goal**: Provide a cached-only “Directory → Groups” browse/search/filter/detail UI. + +**Independent Test**: After a sync run, open the groups list and verify search/filter/detail views work using only cached data. + +### Tests for User Story 2 (REQUIRED) ⚠️ + +- [x] T022 [P] [US2] Add test for listing/searching/filtering cached groups in tests/Feature/DirectoryGroups/BrowseGroupsTest.php + +### Implementation for User Story 2 + +- [x] T023 [P] [US2] Create Filament groups resource in app/Filament/Resources/EntraGroupResource.php and app/Filament/Resources/EntraGroupResource/Pages/{ListEntraGroups,ViewEntraGroup}.php +- [x] T024 [US2] Implement list query + filters (q search, stale filter, group type filter) in app/Filament/Resources/EntraGroupResource.php +- [x] T025 [US2] Add “Sync Groups” header action that links to run list or starts a sync in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php + +**Checkpoint**: US2 complete — operators can browse/search cached groups and triage staleness. + +--- + +## Phase 5: User Story 3 - Name resolution across the suite (Priority: P3) + +**Goal**: Any UI that displays group IDs shows a friendly cached label (or unresolved fallback) without live directory calls during render. + +**Independent Test**: Load a page that includes group GUID references and verify it renders with names from DB cache (and fallbacks when missing) without calling Graph. + +### Tests for User Story 3 (REQUIRED) ⚠️ + +- [x] T026 [P] [US3] Add unit test for label resolver formatting + fallbacks in tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php +- [x] T027 [P] [US3] Add feature test ensuring Tenant/Restore/PolicyVersion UI renders without Graph calls during render in tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php + +### Implementation for User Story 3 + +- [x] T028 [US3] Implement DB-backed label resolver in app/Services/Directory/EntraGroupLabelResolver.php (resolveOne/resolveMany, tenant-scoped, stable formatting) +- [x] T029 [US3] Refactor group label rendering in app/Filament/Resources/TenantResource.php to use EntraGroupLabelResolver instead of Graph lookups +- [x] T030 [US3] Refactor Livewire assignments widget to use cached labels: app/Livewire/PolicyVersionAssignmentsWidget.php and resources/views/livewire/policy-version-assignments-widget.blade.php +- [x] T031 [US3] Refactor restore results to show cached labels where possible: resources/views/filament/infolists/entries/restore-results.blade.php +- [x] T032 [US3] Refactor restore group-mapping inputs/labels to prefer cached labels: app/Filament/Resources/RestoreRunResource.php + +**Checkpoint**: US3 complete — name resolution is consistent and render-safe across key pages. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Validation, cleanup, and operational readiness. + +- [x] T033 [P] Run formatting on changed files with vendor/bin/pint --dirty +- [x] T034 Run targeted Pest suite for this feature (e.g., php artisan test tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups) +- [x] T035 Validate operator workflow against specs/051-entra-group-directory-cache/quickstart.md + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)** → blocks nothing, but should be done first. +- **Foundational (Phase 2)** → BLOCKS all user stories. +- **User Stories (Phase 3–5)** → depend on Foundational; proceed in priority order (US1 → US2 → US3). +- **Polish (Phase 6)** → depends on all desired user stories. + +### User Story Dependencies + +- **US1 (P1)**: Depends on Phase 2 only. +- **US2 (P2)**: Depends on US1 (needs data to browse). +- **US3 (P3)**: Depends on US1 (needs cache populated); can begin in parallel with US2 once US1 is stable. + +--- + +## Parallel Execution Examples + +### US1 + +- Parallel tests: T010–T013 +- Parallel foundational code: T014 and T018 can be developed alongside T015–T016 once schema/models exist. + +### US2 + +- T023 can start while T022 is being written (different files). + +### US3 + +- T028 can start while T026/T027 are being written; integration tasks T029–T032 can be split across different files. + +--- + +## Implementation Strategy + +### MVP (US1 only) + +1. Phase 1 → Phase 2 +2. Phase 3 (US1): tests → sync service/job → run UI → scheduler +3. Stop and validate via quickstart workflow + +### Incremental Delivery + +- Add US2 (browse) after US1 is stable. +- Add US3 (name resolution) after US1, then refactor page-by-page. diff --git a/tests/Feature/DirectoryGroups/BrowseGroupsTest.php b/tests/Feature/DirectoryGroups/BrowseGroupsTest.php new file mode 100644 index 0000000..d31cba6 --- /dev/null +++ b/tests/Feature/DirectoryGroups/BrowseGroupsTest.php @@ -0,0 +1,114 @@ +actingAs($user); + + $otherTenant = Tenant::factory()->create(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + + EntraGroup::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000001', + 'display_name' => 'Alpha Team', + 'group_types' => null, + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now(), + ]); + + EntraGroup::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000002', + 'display_name' => 'Beta Unified', + 'group_types' => ['Unified'], + 'security_enabled' => false, + 'mail_enabled' => true, + 'last_seen_at' => now()->subDays(max(1, $stalenessDays) + 1), + ]); + + EntraGroup::query()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000003', + 'display_name' => 'Other Tenant Group', + 'group_types' => null, + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now(), + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $extractNames = function ($livewire): Collection { + $records = $livewire->instance()->getTableRecords(); + + $items = method_exists($records, 'items') ? collect($records->items()) : collect($records); + + return $items->pluck('display_name'); + }; + + $names = $extractNames(Livewire::test(ListEntraGroups::class)); + expect($names)->toContain('Alpha Team'); + expect($names)->toContain('Beta Unified'); + expect($names)->not->toContain('Other Tenant Group'); + + $names = $extractNames( + Livewire::test(ListEntraGroups::class) + ->set('tableSearch', 'Beta') + ); + expect($names)->toContain('Beta Unified'); + expect($names)->not->toContain('Alpha Team'); + + $names = $extractNames( + Livewire::test(ListEntraGroups::class) + ->set('tableFilters.stale.value', 1) + ); + expect($names)->toContain('Beta Unified'); + expect($names)->not->toContain('Alpha Team'); + + $names = $extractNames( + Livewire::test(ListEntraGroups::class) + ->set('tableFilters.group_type.value', 'security') + ); + expect($names)->toContain('Alpha Team'); + expect($names)->not->toContain('Beta Unified'); +}); + +test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $groupB = EntraGroup::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000099', + 'display_name' => 'Tenant B Group', + 'group_types' => null, + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now(), + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA)) + ->assertForbidden(); +}); diff --git a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php new file mode 100644 index 0000000..dc67eb9 --- /dev/null +++ b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php @@ -0,0 +1,59 @@ +tenant = Tenant::factory()->create(); + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + + $this->user = User::factory()->create(); + $this->user->tenants()->syncWithoutDetaching([ + $this->tenant->getKey() => ['role' => 'owner'], + ]); +}); + +it('renders policy version view without any Graph calls during render', function () { + mock(GraphClientInterface::class) + ->shouldNotReceive('listPolicies') + ->shouldNotReceive('getPolicy') + ->shouldNotReceive('getOrganization') + ->shouldNotReceive('applyPolicy') + ->shouldNotReceive('getServicePrincipalPermissions') + ->shouldNotReceive('request'); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); + + $response->assertOk(); +}); diff --git a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php new file mode 100644 index 0000000..7517372 --- /dev/null +++ b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php @@ -0,0 +1,38 @@ +create(); + + Config::set('directory_groups.schedule.enabled', true); + Config::set('directory_groups.schedule.time_utc', '02:00'); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC')); + + Artisan::call('tenantpilot:directory-groups:dispatch', [ + '--tenant' => [$tenant->tenant_id], + ]); + + $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; + + $run = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', 'groups-v1:all') + ->where('slot_key', $slotKey) + ->first(); + + expect($run)->not->toBeNull() + ->and($run->initiator_user_id)->toBeNull(); + + Queue::assertPushed(EntraGroupSyncJob::class); + + CarbonImmutable::setTestNow(); +}); diff --git a/tests/Feature/DirectoryGroups/StartSyncTest.php b/tests/Feature/DirectoryGroups/StartSyncTest.php new file mode 100644 index 0000000..982ea0a --- /dev/null +++ b/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -0,0 +1,24 @@ +startManualSync($tenant, $user); + + expect($run)->toBeInstanceOf(EntraGroupSyncRun::class) + ->and($run->tenant_id)->toBe($tenant->getKey()) + ->and($run->initiator_user_id)->toBe($user->getKey()) + ->and($run->selection_key)->toBe('groups-v1:all') + ->and($run->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); + + Queue::assertPushed(EntraGroupSyncJob::class); +}); diff --git a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php new file mode 100644 index 0000000..df3f1d8 --- /dev/null +++ b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -0,0 +1,88 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + EntraGroup::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => '11111111-1111-1111-1111-111111111111', + 'display_name' => 'Old Name', + 'last_seen_at' => now('UTC')->subDays(10), + ]); + + $mock = \Mockery::mock(GraphClientInterface::class); + $mock->shouldReceive('request') + ->twice() + ->andReturn( + new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => '11111111-1111-1111-1111-111111111111', + 'displayName' => 'New Name', + 'groupTypes' => ['Unified'], + 'securityEnabled' => false, + 'mailEnabled' => true, + ], + ], + '@odata.nextLink' => 'https://graph.microsoft.com/v1.0/groups?$skiptoken=abc', + ], status: 200), + new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => '22222222-2222-2222-2222-222222222222', + 'displayName' => 'Second Group', + 'groupTypes' => [], + 'securityEnabled' => true, + 'mailEnabled' => false, + ], + ], + ], status: 200), + ); + + app()->instance(GraphClientInterface::class, $mock); + + $job = new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: 'groups-v1:all', + slotKey: null, + runId: (int) $run->getKey(), + ); + + $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); + + $run->refresh(); + + expect($run->status)->toBe(EntraGroupSyncRun::STATUS_SUCCEEDED) + ->and($run->pages_fetched)->toBe(2) + ->and($run->items_observed_count)->toBe(2) + ->and($run->items_upserted_count)->toBe(2) + ->and($run->error_count)->toBe(0) + ->and($run->finished_at)->not->toBeNull(); + + expect(EntraGroup::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2); + + $updated = EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->where('entra_id', '11111111-1111-1111-1111-111111111111') + ->first(); + + expect($updated)->not->toBeNull() + ->and($updated->display_name)->toBe('New Name') + ->and($updated->mail_enabled)->toBeTrue(); +}); diff --git a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php new file mode 100644 index 0000000..da21325 --- /dev/null +++ b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -0,0 +1,51 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'last_seen_at' => now('UTC')->subDays(91), + ]); + + $newGroup = EntraGroup::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'last_seen_at' => now('UTC')->subDays(10), + ]); + + $run = EntraGroupSyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + $mock = \Mockery::mock(GraphClientInterface::class); + $mock->shouldReceive('request') + ->once() + ->andReturn(new GraphResponse(success: true, data: ['value' => []], status: 200)); + app()->instance(GraphClientInterface::class, $mock); + + $job = new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: 'groups-v1:all', + slotKey: null, + runId: (int) $run->getKey(), + ); + + $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); + + expect(EntraGroup::query()->whereKey($oldGroup->getKey())->exists())->toBeFalse(); + expect(EntraGroup::query()->whereKey($newGroup->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php new file mode 100644 index 0000000..e696853 --- /dev/null +++ b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php @@ -0,0 +1,98 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => 'slot-a', + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + ]); + + EntraGroupSyncRun::query()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => 'slot-b', + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('slot-a') + ->assertDontSee('slot-b'); +}); + +test('entra group sync run view is forbidden cross-tenant (403)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $runB = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) + ->assertForbidden(); +}); + +test('sync groups action enqueues job and writes database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroupSyncRuns::class) + ->callAction('sync_groups'); + + Queue::assertPushed(EntraGroupSyncJob::class); + + $run = EntraGroupSyncRun::query()->where('tenant_id', $tenant->getKey())->latest('id')->first(); + expect($run)->not->toBeNull(); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => RunStatusChangedNotification::class, + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index a07e792..b713bca 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -202,3 +202,32 @@ 'action' => 'restore.group_mapping.applied', ]); }); + +test('restore wizard can fill a group mapping entry from directory cache picker', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + $sourceGroupId = fake()->uuid(); + $targetGroupId = fake()->uuid(); + + Livewire::test(CreateRestoreRun::class) + ->set('data.check_summary', 'old') + ->set('data.check_results', ['x']) + ->call('applyEntraGroupCachePick', sourceGroupId: $sourceGroupId, entraId: $targetGroupId) + ->assertSet("data.group_mapping.{$sourceGroupId}", $targetGroupId) + ->assertSet('data.check_summary', null) + ->assertSet('data.check_results', []); +}); diff --git a/tests/Unit/BulkOperationRunProgressTest.php b/tests/Unit/BulkOperationRunProgressTest.php index 0a9e71e..a36a66f 100644 --- a/tests/Unit/BulkOperationRunProgressTest.php +++ b/tests/Unit/BulkOperationRunProgressTest.php @@ -29,3 +29,33 @@ ->and($run->failures)->toBeArray() ->and($run->failures)->toHaveCount(1); }); + +test('bulk operation run total_items is at least the item_ids count', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['a', 'b', 'c', 'd'], 1); + + expect($run->total_items)->toBe(4); +}); + +test('bulk operation completion clamps total_items up to processed_items', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['only-one'], 1); + + $service->start($run); + $service->recordSuccess($run); + $service->recordSuccess($run); + + $run->refresh(); + expect($run->processed_items)->toBe(2)->and($run->total_items)->toBe(1); + + $service->complete($run); + $run->refresh(); + + expect($run->total_items)->toBe(2); +}); diff --git a/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php b/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php new file mode 100644 index 0000000..189f9eb --- /dev/null +++ b/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php @@ -0,0 +1,50 @@ +toBe('Unresolved (…55555555)'); +}); + +it('resolves labels from the tenant cache (tenant-scoped)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $entraId = '11111111-2222-3333-4444-555555555555'; + + EntraGroup::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'entra_id' => $entraId, + 'display_name' => 'Alpha Team', + ]); + + EntraGroup::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'entra_id' => $entraId, + 'display_name' => 'Beta Team', + ]); + + $resolver = app(EntraGroupLabelResolver::class); + + expect($resolver->resolveOne($tenantA, $entraId)) + ->toBe('Alpha Team (…55555555)') + ->and($resolver->resolveOne($tenantB, $entraId)) + ->toBe('Beta Team (…55555555)'); +}); + +it('returns a fallback without querying invalid UUIDs', function () { + $tenant = Tenant::factory()->create(); + + $resolver = app(EntraGroupLabelResolver::class); + + expect($resolver->resolveOne($tenant, 'group-123')) + ->toBe('Unresolved (group123)'); +}); From 242881c04e2a604059fea44c8496bcef4ec7bfe0 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 13 Jan 2026 23:48:16 +0100 Subject: [PATCH 2/5] feat(044): add drift findings foundation --- app/Filament/Pages/DriftLanding.php | 83 +++++++++++++++++++ app/Filament/Resources/FindingResource.php | 65 +++++++++++++++ .../FindingResource/Pages/ListFindings.php | 11 +++ .../FindingResource/Pages/ViewFinding.php | 11 +++ app/Jobs/GenerateDriftFindingsJob.php | 30 +++++++ app/Models/Finding.php | 65 +++++++++++++++ app/Policies/FindingPolicy.php | 66 +++++++++++++++ app/Providers/AppServiceProvider.php | 5 ++ app/Services/Drift/DriftEvidence.php | 31 +++++++ app/Services/Drift/DriftHasher.php | 33 ++++++++ app/Services/Drift/DriftRunSelector.php | 40 +++++++++ app/Services/Drift/DriftScopeKey.php | 13 +++ database/factories/FindingFactory.php | 37 +++++++++ ...026_01_13_223311_create_findings_table.php | 56 +++++++++++++ .../filament/pages/drift-landing.blade.php | 15 ++++ .../Drift/DriftBaselineSelectionTest.php | 60 ++++++++++++++ .../Drift/DriftGenerationDispatchTest.php | 60 ++++++++++++++ 17 files changed, 681 insertions(+) create mode 100644 app/Filament/Pages/DriftLanding.php create mode 100644 app/Filament/Resources/FindingResource.php create mode 100644 app/Filament/Resources/FindingResource/Pages/ListFindings.php create mode 100644 app/Filament/Resources/FindingResource/Pages/ViewFinding.php create mode 100644 app/Jobs/GenerateDriftFindingsJob.php create mode 100644 app/Models/Finding.php create mode 100644 app/Policies/FindingPolicy.php create mode 100644 app/Services/Drift/DriftEvidence.php create mode 100644 app/Services/Drift/DriftHasher.php create mode 100644 app/Services/Drift/DriftRunSelector.php create mode 100644 app/Services/Drift/DriftScopeKey.php create mode 100644 database/factories/FindingFactory.php create mode 100644 database/migrations/2026_01_13_223311_create_findings_table.php create mode 100644 resources/views/filament/pages/drift-landing.blade.php create mode 100644 tests/Feature/Drift/DriftBaselineSelectionTest.php create mode 100644 tests/Feature/Drift/DriftGenerationDispatchTest.php diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php new file mode 100644 index 0000000..cfdcb44 --- /dev/null +++ b/app/Filament/Pages/DriftLanding.php @@ -0,0 +1,83 @@ +user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + $latestSuccessful = InventorySyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('status', InventorySyncRun::STATUS_SUCCESS) + ->whereNotNull('finished_at') + ->orderByDesc('finished_at') + ->first(); + + if (! $latestSuccessful instanceof InventorySyncRun) { + return; + } + + $scopeKey = (string) $latestSuccessful->selection_hash; + + $selector = app(DriftRunSelector::class); + $comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey); + + if ($comparison === null) { + return; + } + + $baseline = $comparison['baseline']; + $current = $comparison['current']; + + $exists = Finding::query() + ->where('tenant_id', $tenant->getKey()) + ->where('finding_type', Finding::FINDING_TYPE_DRIFT) + ->where('scope_key', $scopeKey) + ->where('baseline_run_id', $baseline->getKey()) + ->where('current_run_id', $current->getKey()) + ->exists(); + + if ($exists) { + return; + } + + GenerateDriftFindingsJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + baselineRunId: (int) $baseline->getKey(), + currentRunId: (int) $current->getKey(), + scopeKey: $scopeKey, + ); + } + + public function getFindingsUrl(): string + { + return FindingResource::getUrl('index', tenant: Tenant::current()); + } +} diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php new file mode 100644 index 0000000..bfd097e --- /dev/null +++ b/app/Filament/Resources/FindingResource.php @@ -0,0 +1,65 @@ +columns([ + Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'), + Tables\Columns\TextColumn::make('status')->badge(), + Tables\Columns\TextColumn::make('severity')->badge(), + Tables\Columns\TextColumn::make('subject_type')->label('Subject')->searchable(), + Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true), + Tables\Columns\TextColumn::make('created_at')->since()->label('Created'), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListFindings::route('/'), + 'view' => Pages\ViewFinding::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php new file mode 100644 index 0000000..cb872dd --- /dev/null +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -0,0 +1,11 @@ + */ + use HasFactory; + + public const string FINDING_TYPE_DRIFT = 'drift'; + + public const string SEVERITY_LOW = 'low'; + + public const string SEVERITY_MEDIUM = 'medium'; + + public const string SEVERITY_HIGH = 'high'; + + public const string STATUS_NEW = 'new'; + + public const string STATUS_ACKNOWLEDGED = 'acknowledged'; + + protected $guarded = []; + + protected $casts = [ + 'acknowledged_at' => 'datetime', + 'evidence_jsonb' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function baselineRun(): BelongsTo + { + return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id'); + } + + public function currentRun(): BelongsTo + { + return $this->belongsTo(InventorySyncRun::class, 'current_run_id'); + } + + public function acknowledgedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'acknowledged_by_user_id'); + } + + public function acknowledge(User $user): void + { + if ($this->status === self::STATUS_ACKNOWLEDGED) { + return; + } + + $this->forceFill([ + 'status' => self::STATUS_ACKNOWLEDGED, + 'acknowledged_at' => now(), + 'acknowledged_by_user_id' => $user->getKey(), + ]); + } +} diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php new file mode 100644 index 0000000..6a779e0 --- /dev/null +++ b/app/Policies/FindingPolicy.php @@ -0,0 +1,66 @@ +canAccessTenant($tenant); + } + + public function view(User $user, Finding $finding): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $finding->tenant_id === (int) $tenant->getKey(); + } + + public function update(User $user, Finding $finding): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + if ((int) $finding->tenant_id !== (int) $tenant->getKey()) { + return false; + } + + $role = $user->tenantRole($tenant); + + return match ($role) { + TenantRole::Owner, + TenantRole::Manager, + TenantRole::Operator => true, + default => false, + }; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a605e4a..8e59c1e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,10 +3,14 @@ namespace App\Providers; use App\Models\BackupSchedule; +use App\Models\BulkOperationRun; +use App\Models\Finding; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; use App\Policies\BackupSchedulePolicy; +use App\Policies\BulkOperationRunPolicy; +use App\Policies\FindingPolicy; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; @@ -108,5 +112,6 @@ public function boot(): void Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class); + Gate::policy(Finding::class, FindingPolicy::class); } } diff --git a/app/Services/Drift/DriftEvidence.php b/app/Services/Drift/DriftEvidence.php new file mode 100644 index 0000000..f5c4d96 --- /dev/null +++ b/app/Services/Drift/DriftEvidence.php @@ -0,0 +1,31 @@ + $payload + * @return array + */ + public function sanitize(array $payload): array + { + $allowedKeys = [ + 'change_type', + 'summary', + 'baseline', + 'current', + 'diff', + 'notes', + ]; + + $safe = []; + foreach ($allowedKeys as $key) { + if (array_key_exists($key, $payload)) { + $safe[$key] = $payload[$key]; + } + } + + return $safe; + } +} diff --git a/app/Services/Drift/DriftHasher.php b/app/Services/Drift/DriftHasher.php new file mode 100644 index 0000000..6ca3aee --- /dev/null +++ b/app/Services/Drift/DriftHasher.php @@ -0,0 +1,33 @@ +normalize($scopeKey), + $this->normalize($subjectType), + $this->normalize($subjectExternalId), + $this->normalize($changeType), + $this->normalize($baselineHash), + $this->normalize($currentHash), + ]; + + return hash('sha256', implode('|', $parts)); + } + + private function normalize(string $value): string + { + return trim(mb_strtolower($value)); + } +} diff --git a/app/Services/Drift/DriftRunSelector.php b/app/Services/Drift/DriftRunSelector.php new file mode 100644 index 0000000..a320f5d --- /dev/null +++ b/app/Services/Drift/DriftRunSelector.php @@ -0,0 +1,40 @@ +where('tenant_id', $tenant->getKey()) + ->where('selection_hash', $scopeKey) + ->where('status', InventorySyncRun::STATUS_SUCCESS) + ->whereNotNull('finished_at') + ->orderByDesc('finished_at') + ->limit(2) + ->get(); + + if ($runs->count() < 2) { + return null; + } + + $current = $runs->first(); + $baseline = $runs->last(); + + if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) { + return null; + } + + return [ + 'baseline' => $baseline, + 'current' => $current, + ]; + } +} diff --git a/app/Services/Drift/DriftScopeKey.php b/app/Services/Drift/DriftScopeKey.php new file mode 100644 index 0000000..f6e9405 --- /dev/null +++ b/app/Services/Drift/DriftScopeKey.php @@ -0,0 +1,13 @@ +selection_hash; + } +} diff --git a/database/factories/FindingFactory.php b/database/factories/FindingFactory.php new file mode 100644 index 0000000..408ce4e --- /dev/null +++ b/database/factories/FindingFactory.php @@ -0,0 +1,37 @@ + + */ +class FindingFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'finding_type' => Finding::FINDING_TYPE_DRIFT, + 'scope_key' => hash('sha256', fake()->uuid()), + 'baseline_run_id' => null, + 'current_run_id' => null, + 'fingerprint' => hash('sha256', fake()->uuid()), + 'subject_type' => 'assignment', + 'subject_external_id' => fake()->uuid(), + 'severity' => Finding::SEVERITY_MEDIUM, + 'status' => Finding::STATUS_NEW, + 'acknowledged_at' => null, + 'acknowledged_by_user_id' => null, + 'evidence_jsonb' => [], + ]; + } +} diff --git a/database/migrations/2026_01_13_223311_create_findings_table.php b/database/migrations/2026_01_13_223311_create_findings_table.php new file mode 100644 index 0000000..5307a51 --- /dev/null +++ b/database/migrations/2026_01_13_223311_create_findings_table.php @@ -0,0 +1,56 @@ +id(); + + $table->foreignId('tenant_id')->constrained(); + + $table->string('finding_type'); + $table->string('scope_key'); + + $table->foreignId('baseline_run_id')->nullable()->constrained('inventory_sync_runs'); + $table->foreignId('current_run_id')->nullable()->constrained('inventory_sync_runs'); + + $table->string('fingerprint', 64); + + $table->string('subject_type'); + $table->string('subject_external_id'); + + $table->string('severity'); + $table->string('status'); + + $table->timestampTz('acknowledged_at')->nullable(); + $table->foreignId('acknowledged_by_user_id')->nullable()->constrained('users'); + + $table->jsonb('evidence_jsonb')->nullable(); + + $table->timestamps(); + + $table->unique(['tenant_id', 'fingerprint']); + + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'scope_key']); + $table->index(['tenant_id', 'baseline_run_id']); + $table->index(['tenant_id', 'current_run_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('findings'); + } +}; diff --git a/resources/views/filament/pages/drift-landing.blade.php b/resources/views/filament/pages/drift-landing.blade.php new file mode 100644 index 0000000..9b631d5 --- /dev/null +++ b/resources/views/filament/pages/drift-landing.blade.php @@ -0,0 +1,15 @@ + + +
+
+ Review new drift findings between the last two inventory sync runs for the current scope. +
+ +
+ + Findings + +
+
+
+
diff --git a/tests/Feature/Drift/DriftBaselineSelectionTest.php b/tests/Feature/Drift/DriftBaselineSelectionTest.php new file mode 100644 index 0000000..c0e273b --- /dev/null +++ b/tests/Feature/Drift/DriftBaselineSelectionTest.php @@ -0,0 +1,60 @@ +actingAs($user); + + $scopeKey = hash('sha256', 'scope-a'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(3), + ]); + + $baseline = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + $current = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_FAILED, + 'finished_at' => now(), + ]); + + $selector = app(DriftRunSelector::class); + + $selected = $selector->selectBaselineAndCurrent($tenant, $scopeKey); + + expect($selected)->not->toBeNull(); + expect($selected['baseline']->getKey())->toBe($baseline->getKey()); + expect($selected['current']->getKey())->toBe($current->getKey()); +}); + +test('it returns null when fewer than two successful runs exist for scope', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + + $scopeKey = hash('sha256', 'scope-b'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + $selector = app(DriftRunSelector::class); + + expect($selector->selectBaselineAndCurrent($tenant, $scopeKey))->toBeNull(); +}); diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php new file mode 100644 index 0000000..3e54f9a --- /dev/null +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -0,0 +1,60 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $scopeKey = hash('sha256', 'scope-dispatch'); + + $baseline = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDays(2), + ]); + + $current = InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + Livewire::test(DriftLanding::class); + + Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey): bool { + return $job->tenantId === (int) $tenant->getKey() + && $job->userId === (int) $user->getKey() + && $job->baselineRunId === (int) $baseline->getKey() + && $job->currentRunId === (int) $current->getKey() + && $job->scopeKey === $scopeKey; + }); +}); + +test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'manager'); + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $scopeKey = hash('sha256', 'scope-blocked'); + + InventorySyncRun::factory()->for($tenant)->create([ + 'selection_hash' => $scopeKey, + 'status' => InventorySyncRun::STATUS_SUCCESS, + 'finished_at' => now()->subDay(), + ]); + + Livewire::test(DriftLanding::class); + + Queue::assertNothingPushed(); +}); From 68ab61b5c01688c1e7ac8686d641d29caa7d7f44 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 13 Jan 2026 23:55:41 +0100 Subject: [PATCH 3/5] feat(044): generate assignment drift findings --- app/Jobs/GenerateDriftFindingsJob.php | 28 +++- app/Services/Drift/DriftFindingGenerator.php | 127 ++++++++++++++++++ .../DriftAssignmentDriftDetectionTest.php | 76 +++++++++++ .../Drift/DriftTenantIsolationTest.php | 80 +++++++++++ 4 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 app/Services/Drift/DriftFindingGenerator.php create mode 100644 tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php create mode 100644 tests/Feature/Drift/DriftTenantIsolationTest.php diff --git a/app/Jobs/GenerateDriftFindingsJob.php b/app/Jobs/GenerateDriftFindingsJob.php index 8c0de42..645dae4 100644 --- a/app/Jobs/GenerateDriftFindingsJob.php +++ b/app/Jobs/GenerateDriftFindingsJob.php @@ -2,11 +2,15 @@ namespace App\Jobs; +use App\Models\InventorySyncRun; +use App\Models\Tenant; +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 RuntimeException; class GenerateDriftFindingsJob implements ShouldQueue { @@ -23,8 +27,28 @@ public function __construct( /** * Execute the job. */ - public function handle(): void + public function handle(DriftFindingGenerator $generator): void { - // Implemented in later tasks (T020/T021). + $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.'); + } + + $generator->generate( + tenant: $tenant, + baseline: $baseline, + current: $current, + scopeKey: $this->scopeKey, + ); } } diff --git a/app/Services/Drift/DriftFindingGenerator.php b/app/Services/Drift/DriftFindingGenerator.php new file mode 100644 index 0000000..1fc88fa --- /dev/null +++ b/app/Services/Drift/DriftFindingGenerator.php @@ -0,0 +1,127 @@ +finished_at || ! $current->finished_at) { + throw new RuntimeException('Baseline/current run must be finished.'); + } + + /** @var array $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) { + continue; + } + + $baselineAssignmentsHash = $baselineVersion->assignments_hash ?? null; + $currentAssignmentsHash = $currentVersion->assignments_hash ?? null; + + if ($baselineAssignmentsHash === $currentAssignmentsHash) { + continue; + } + + $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' => 'Policy assignments changed', + '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::query()->updateOrCreate( + [ + 'tenant_id' => $tenant->getKey(), + 'fingerprint' => $fingerprint, + ], + [ + '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, + 'status' => Finding::STATUS_NEW, + 'acknowledged_at' => null, + 'acknowledged_by_user_id' => null, + 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), + ], + ); + + $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(); + } +} diff --git a/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php new file mode 100644 index 0000000..5a186ea --- /dev/null +++ b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php @@ -0,0 +1,76 @@ +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, + 'assignments_hash' => hash('sha256', json_encode($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, + 'assignments_hash' => hash('sha256', json_encode($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'); +}); diff --git a/tests/Feature/Drift/DriftTenantIsolationTest.php b/tests/Feature/Drift/DriftTenantIsolationTest.php new file mode 100644 index 0000000..a6661a2 --- /dev/null +++ b/tests/Feature/Drift/DriftTenantIsolationTest.php @@ -0,0 +1,80 @@ +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); +}); From 2214d8acc6821bd56cdc37c91bd1eb36b548aaec Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Wed, 14 Jan 2026 00:12:37 +0100 Subject: [PATCH 4/5] spec(044): record BulkOperationRun decision --- .../044-drift-mvp/checklists/requirements.md | 32 +++++++++---------- specs/044-drift-mvp/spec.md | 8 ++++- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/specs/044-drift-mvp/checklists/requirements.md b/specs/044-drift-mvp/checklists/requirements.md index 5e432f1..04dab55 100644 --- a/specs/044-drift-mvp/checklists/requirements.md +++ b/specs/044-drift-mvp/checklists/requirements.md @@ -6,28 +6,28 @@ # Specification Quality Checklist: Drift MVP (044) ## Content Quality -- [ ] No implementation details (languages, frameworks, APIs) -- [ ] Focused on user value and business needs -- [ ] Written for non-technical stakeholders -- [ ] All mandatory sections completed +- [ ] No implementation details (languages, frameworks, APIs) (T002) +- [x] Focused on user value and business needs (spec.md: Purpose, User Scenarios & Testing, Success Criteria) +- [ ] Written for non-technical stakeholders (T002) +- [x] All mandatory sections completed (spec.md includes Purpose, Scenarios, FR/NFR, Success Criteria, Out of Scope) ## Requirement Completeness -- [ ] No [NEEDS CLARIFICATION] markers remain -- [ ] Requirements are testable and unambiguous -- [ ] Success criteria are measurable -- [ ] Success criteria are technology-agnostic (no implementation details) -- [ ] All acceptance scenarios are defined -- [ ] Edge cases are identified -- [ ] Scope is clearly bounded -- [ ] Dependencies and assumptions identified +- [x] No [NEEDS CLARIFICATION] markers remain (spec.md: no "[NEEDS CLARIFICATION]" markers) +- [x] Requirements are testable and unambiguous (spec.md: FR1–FR4; tasks.md defines tests for key behaviors T015–T018, T024–T025, T029–T030, T035, T038) +- [x] Success criteria are measurable (spec.md: SC1 "under 3 minutes", SC2 deterministic consistency) +- [x] Success criteria are technology-agnostic (no implementation details) (spec.md: SC1–SC2) +- [x] All acceptance scenarios are defined (spec.md: Scenario 1/2/3) +- [x] Edge cases are identified (spec.md: <2 runs blocked state; generation failure explicit error state; acknowledgement per comparison) +- [x] Scope is clearly bounded (spec.md: FR2b + Out of Scope) +- [x] Dependencies and assumptions identified (spec.md: Dependencies / Name Resolution; NFR2; "No render-time Graph calls") ## Feature Readiness -- [ ] All functional requirements have clear acceptance criteria -- [ ] User scenarios cover primary flows -- [ ] Feature meets measurable outcomes defined in Success Criteria -- [ ] No implementation details leak into specification +- [x] All functional requirements have clear acceptance criteria (spec.md: FR1–FR4 + Scenario 1/2/3) +- [x] User scenarios cover primary flows (spec.md: Scenario 1/2/3) +- [ ] Feature meets measurable outcomes defined in Success Criteria (T022, T023, T026, T027, T031, T033, T035) +- [ ] No implementation details leak into specification (T002) ## Notes diff --git a/specs/044-drift-mvp/spec.md b/specs/044-drift-mvp/spec.md index 27031cb..a3a2a61 100644 --- a/specs/044-drift-mvp/spec.md +++ b/specs/044-drift-mvp/spec.md @@ -28,6 +28,10 @@ ### Session 2026-01-13 - Q: What is the default UI behavior for `new` vs `acknowledged` findings? → A: Default UI shows only `new`; `acknowledged` is accessible via an explicit filter. - Q: What should the UI do if drift generation fails for a comparison? → A: Show an explicit error state (safe message + reference/run ids) and do not show findings for that comparison until a successful generation exists. +### Session 2026-01-14 + +- Q: How should Drift track generation status/errors/idempotency for a comparison? → A: Use `BulkOperationRun` as the canonical run container (status, failures, idempotency_key, and consistent UI/ops patterns). + ## Pinned Decisions (MVP defaults) - Drift is implemented as a generator that writes persisted Finding rows (not only an in-memory/on-demand diff). @@ -37,6 +41,7 @@ ## Pinned Decisions (MVP defaults) - Drift MVP only uses `finding_type=drift` and `status` in {`new`, `acknowledged`}. - Default severity: `medium` (until a rule engine exists). - UI must not perform render-time Graph calls. Graph access (if any) is limited to background sync/jobs. +- Drift generation is tracked via `BulkOperationRun` to persist status/errors across refresh and to enforce idempotency per (tenant, scope_key, baseline_run_id, current_run_id). ## Key Entities / Generic Findings (Future-proof) @@ -101,6 +106,7 @@ ## Functional Requirements - Findings cover adds, removals, and changes for supported entities (Policies + Assignments). - MVP `change_type` values: `added`, `removed`, `modified`. - Findings are deterministic: same baseline/current + scope_key ⇒ same set of fingerprints. + - Drift generation must be tracked via `BulkOperationRun` with an idempotency key derived from (tenant_id, scope_key, baseline_run_id, current_run_id). - If fewer than two successful inventory runs exist for a given `scope_key`, Drift does not generate findings and must surface a clear blocked/empty state in the UI. - FR2a: Fingerprint definition (MVP) @@ -114,7 +120,7 @@ ## Functional Requirements - FR3: Provide Drift UI with summary and details. - Default lists and the Drift landing summary show only `status=new` by default. - The UI must provide a filter to include `acknowledged` findings. - - If drift generation fails for a comparison, the UI must surface an explicit error state (no secrets), including reference identifiers (e.g., run ids), and must not fall back to stale/previous results. + - If drift generation fails for a comparison, the UI must surface an explicit error state (no secrets), including reference identifiers (e.g., run ids and the `BulkOperationRun` id), and must not fall back to stale/previous results. - FR4: Triage (MVP) - Admin can acknowledge a finding; record `acknowledged_by_user_id` + `acknowledged_at`. From 66b46955811e25d9c32b8de33f1877b10bd07476 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Thu, 15 Jan 2026 00:12:55 +0100 Subject: [PATCH 5/5] feat(044): drift findings UI + bulk acknowledge --- app/Filament/Pages/DriftLanding.php | 152 ++++++++ app/Filament/Resources/FindingResource.php | 329 +++++++++++++++++- .../FindingResource/Pages/ListFindings.php | 160 +++++++++ app/Jobs/GenerateDriftFindingsJob.php | 64 +++- app/Models/Finding.php | 2 + .../Drift/DriftFindingDiffBuilder.php | 304 ++++++++++++++++ app/Services/Drift/DriftFindingGenerator.php | 250 +++++++++++-- app/Services/Drift/DriftHasher.php | 68 ++++ .../Normalizers/AssignmentsNormalizer.php | 113 ++++++ .../Drift/Normalizers/ScopeTagsNormalizer.php | 136 ++++++++ .../Drift/Normalizers/SettingsNormalizer.php | 19 + config/intune_permissions.php | 6 +- .../entries/assignments-diff.blade.php | 114 ++++++ .../entries/scope-tags-diff.blade.php | 111 ++++++ .../filament/pages/drift-landing.blade.php | 92 +++++ .../044-drift-mvp/checklists/requirements.md | 26 +- .../contracts/admin-findings.openapi.yaml | 21 ++ specs/044-drift-mvp/plan.md | 2 + specs/044-drift-mvp/quickstart.md | 8 +- specs/044-drift-mvp/spec.md | 185 +++------- specs/044-drift-mvp/tasks.md | 113 +++--- .../DriftAcknowledgeAuthorizationTest.php | 31 ++ tests/Feature/Drift/DriftAcknowledgeTest.php | 28 ++ .../DriftAssignmentDriftDetectionTest.php | 2 - ...AcknowledgeAllMatchingConfirmationTest.php | 34 ++ .../DriftBulkAcknowledgeAllMatchingTest.php | 51 +++ .../DriftBulkAcknowledgeAuthorizationTest.php | 60 ++++ .../Drift/DriftBulkAcknowledgeTest.php | 34 ++ .../DriftCompletedRunWithZeroFindingsTest.php | 71 ++++ .../Drift/DriftEvidenceMinimizationTest.php | 24 ++ ...tFindingDetailShowsAssignmentsDiffTest.php | 151 ++++++++ ...iftFindingDetailShowsScopeTagsDiffTest.php | 100 ++++++ ...riftFindingDetailShowsSettingsDiffTest.php | 100 ++++++ .../Feature/Drift/DriftFindingDetailTest.php | 48 +++ .../Drift/DriftGenerationDeterminismTest.php | 76 ++++ .../Drift/DriftGenerationDispatchTest.php | 73 +++- tests/Feature/Drift/DriftHasherTest.php | 39 +++ .../DriftLandingShowsComparisonInfoTest.php | 33 ++ .../DriftPolicySnapshotDriftDetectionTest.php | 73 ++++ ...otMetadataOnlyDoesNotCreateFindingTest.php | 62 ++++ .../Drift/DriftScopeTagDriftDetectionTest.php | 84 +++++ ...gLegacyDefaultDoesNotCreateFindingTest.php | 69 ++++ 42 files changed, 3276 insertions(+), 242 deletions(-) create mode 100644 app/Services/Drift/DriftFindingDiffBuilder.php create mode 100644 app/Services/Drift/Normalizers/AssignmentsNormalizer.php create mode 100644 app/Services/Drift/Normalizers/ScopeTagsNormalizer.php create mode 100644 app/Services/Drift/Normalizers/SettingsNormalizer.php create mode 100644 resources/views/filament/infolists/entries/assignments-diff.blade.php create mode 100644 resources/views/filament/infolists/entries/scope-tags-diff.blade.php create mode 100644 tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php create mode 100644 tests/Feature/Drift/DriftAcknowledgeTest.php create mode 100644 tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php create mode 100644 tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php create mode 100644 tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php create mode 100644 tests/Feature/Drift/DriftBulkAcknowledgeTest.php create mode 100644 tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php create mode 100644 tests/Feature/Drift/DriftEvidenceMinimizationTest.php create mode 100644 tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php create mode 100644 tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php create mode 100644 tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php create mode 100644 tests/Feature/Drift/DriftFindingDetailTest.php create mode 100644 tests/Feature/Drift/DriftGenerationDeterminismTest.php create mode 100644 tests/Feature/Drift/DriftHasherTest.php create mode 100644 tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php create mode 100644 tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php create mode 100644 tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php create mode 100644 tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php create mode 100644 tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index cfdcb44..3b7c1d6 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -2,13 +2,18 @@ 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; @@ -23,6 +28,30 @@ class DriftLanding extends Page 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|null */ + public ?array $statusCounts = null; + + public static function canAccess(): bool + { + return FindingResource::canAccess(); + } + public function mount(): void { $tenant = Tenant::current(); @@ -40,21 +69,45 @@ public function mount(): void ->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) @@ -64,15 +117,87 @@ public function mount(): void ->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(), ); } @@ -80,4 +205,31 @@ 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()); + } } diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index bfd097e..a78c03a 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -4,14 +4,28 @@ 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 @@ -29,22 +43,326 @@ 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_type')->label('Subject')->searchable(), + 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([]); + ->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 @@ -52,6 +370,13 @@ 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)); } diff --git a/app/Filament/Resources/FindingResource/Pages/ListFindings.php b/app/Filament/Resources/FindingResource/Pages/ListFindings.php index cb872dd..0322a04 100644 --- a/app/Filament/Resources/FindingResource/Pages/ListFindings.php +++ b/app/Filament/Resources/FindingResource/Pages/ListFindings.php @@ -3,9 +3,169 @@ 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; + } } diff --git a/app/Jobs/GenerateDriftFindingsJob.php b/app/Jobs/GenerateDriftFindingsJob.php index 645dae4..353cb77 100644 --- a/app/Jobs/GenerateDriftFindingsJob.php +++ b/app/Jobs/GenerateDriftFindingsJob.php @@ -2,15 +2,19 @@ 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 { @@ -22,13 +26,22 @@ public function __construct( public int $baselineRunId, public int $currentRunId, public string $scopeKey, + public int $bulkOperationRunId, ) {} /** * Execute the job. */ - public function handle(DriftFindingGenerator $generator): void + 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.'); @@ -44,11 +57,48 @@ public function handle(DriftFindingGenerator $generator): void throw new RuntimeException('Current run not found.'); } - $generator->generate( - tenant: $tenant, - baseline: $baseline, - current: $current, - scopeKey: $this->scopeKey, - ); + $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; + } } } diff --git a/app/Models/Finding.php b/app/Models/Finding.php index c0e8173..5f94e31 100644 --- a/app/Models/Finding.php +++ b/app/Models/Finding.php @@ -61,5 +61,7 @@ public function acknowledge(User $user): void 'acknowledged_at' => now(), 'acknowledged_by_user_id' => $user->getKey(), ]); + + $this->save(); } } diff --git a/app/Services/Drift/DriftFindingDiffBuilder.php b/app/Services/Drift/DriftFindingDiffBuilder.php new file mode 100644 index 0000000..1196947 --- /dev/null +++ b/app/Services/Drift/DriftFindingDiffBuilder.php @@ -0,0 +1,304 @@ + + */ + 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 + */ + 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 + */ + 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> $added + * @param array> $removed + * @param array> $changed + * @return array + */ + 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 $assignment + * @param array $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); + } +} diff --git a/app/Services/Drift/DriftFindingGenerator.php b/app/Services/Drift/DriftFindingGenerator.php index 1fc88fa..1944925 100644 --- a/app/Services/Drift/DriftFindingGenerator.php +++ b/app/Services/Drift/DriftFindingGenerator.php @@ -7,6 +7,8 @@ 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; @@ -15,6 +17,8 @@ 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 @@ -48,48 +52,139 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy $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; } - $baselineAssignmentsHash = $baselineVersion->assignments_hash ?? null; - $currentAssignmentsHash = $currentVersion->assignments_hash ?? null; + $baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : []; + $currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : []; - if ($baselineAssignmentsHash === $currentAssignmentsHash) { - continue; - } + $baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments); + $currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments); - $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 ?? ''), - ); + 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' => 'Policy assignments changed', - '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, - ], - ]; + $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::query()->updateOrCreate( - [ + $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(), @@ -97,14 +192,97 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy '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, - 'evidence_jsonb' => $this->evidence->sanitize($rawEvidence), - ], - ); + ]); + } - $created++; + $finding->save(); + + if ($wasNew) { + $created++; + } } }); diff --git a/app/Services/Drift/DriftHasher.php b/app/Services/Drift/DriftHasher.php index 6ca3aee..6f9ec9d 100644 --- a/app/Services/Drift/DriftHasher.php +++ b/app/Services/Drift/DriftHasher.php @@ -4,6 +4,24 @@ class DriftHasher { + /** + * @param array $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, @@ -30,4 +48,54 @@ private function normalize(string $value): string { return trim(mb_strtolower($value)); } + + /** + * @param array $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); + } } diff --git a/app/Services/Drift/Normalizers/AssignmentsNormalizer.php b/app/Services/Drift/Normalizers/AssignmentsNormalizer.php new file mode 100644 index 0000000..77b85c6 --- /dev/null +++ b/app/Services/Drift/Normalizers/AssignmentsNormalizer.php @@ -0,0 +1,113 @@ + + */ + 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 $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 ''; + } +} diff --git a/app/Services/Drift/Normalizers/ScopeTagsNormalizer.php b/app/Services/Drift/Normalizers/ScopeTagsNormalizer.php new file mode 100644 index 0000000..a543d15 --- /dev/null +++ b/app/Services/Drift/Normalizers/ScopeTagsNormalizer.php @@ -0,0 +1,136 @@ + + */ + 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|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 + */ + 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; + } +} diff --git a/app/Services/Drift/Normalizers/SettingsNormalizer.php b/app/Services/Drift/Normalizers/SettingsNormalizer.php new file mode 100644 index 0000000..38b352e --- /dev/null +++ b/app/Services/Drift/Normalizers/SettingsNormalizer.php @@ -0,0 +1,19 @@ +|null $snapshot + * @return array + */ + public function normalizeForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->policyNormalizer->flattenForDiff($snapshot ?? [], $policyType, $platform); + } +} diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 609aedc..96677d8 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -6,13 +6,13 @@ 'key' => 'DeviceManagementConfiguration.ReadWrite.All', 'type' => 'application', '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', 'type' => 'application', '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', @@ -72,7 +72,7 @@ 'key' => 'Group.Read.All', 'type' => 'application', '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', diff --git a/resources/views/filament/infolists/entries/assignments-diff.blade.php b/resources/views/filament/infolists/entries/assignments-diff.blade.php new file mode 100644 index 0000000..1118937 --- /dev/null +++ b/resources/views/filament/infolists/entries/assignments-diff.blade.php @@ -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 + +
+ +
+ + {{ (int) ($summary['added'] ?? 0) }} added + + + {{ (int) ($summary['removed'] ?? 0) }} removed + + + {{ (int) ($summary['changed'] ?? 0) }} changed + + + @if (($summary['truncated'] ?? false) === true) + + Truncated to {{ (int) ($summary['limit'] ?? 0) }} items + + @endif +
+
+ + @if ($changed !== []) + +
+ @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 + +
+
+ {{ $to['target_label'] }} +
+ +
+
+
From
+
+
Type: {{ $from['include_exclude'] }}
+
Filter: {{ $from['filter_type'] }}@if($from['filter_id']) ({{ $from['filter_id'] }})@endif
+
+
+
+
To
+
+
Type: {{ $to['include_exclude'] }}
+
Filter: {{ $to['filter_type'] }}@if($to['filter_id']) ({{ $to['filter_id'] }})@endif
+
+
+
+
+ @endforeach +
+
+ @endif + + @if ($added !== []) + +
+ @foreach ($added as $row) + @php $row = $renderRow(is_array($row) ? $row : []); @endphp + +
+
{{ $row['target_label'] }}
+
+ {{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }} +
+
+ @endforeach +
+
+ @endif + + @if ($removed !== []) + +
+ @foreach ($removed as $row) + @php $row = $renderRow(is_array($row) ? $row : []); @endphp + +
+
{{ $row['target_label'] }}
+
+ {{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }} +
+
+ @endforeach +
+
+ @endif +
diff --git a/resources/views/filament/infolists/entries/scope-tags-diff.blade.php b/resources/views/filament/infolists/entries/scope-tags-diff.blade.php new file mode 100644 index 0000000..324f574 --- /dev/null +++ b/resources/views/filament/infolists/entries/scope-tags-diff.blade.php @@ -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 + +
+ +
+ + {{ (int) ($summary['added'] ?? 0) }} added + + + {{ (int) ($summary['removed'] ?? 0) }} removed + + + Baseline: {{ (int) ($summary['baseline_count'] ?? 0) }} + + + Current: {{ (int) ($summary['current_count'] ?? 0) }} + +
+
+ + @if ($added !== []) + +
+ @foreach ($added as $row) + @php + $name = (string) ($row['name'] ?? 'Unknown'); + $id = (string) ($row['id'] ?? ''); + @endphp + +
+
{{ $name }}
+ @if ($id !== '') +
{{ $id }}
+ @endif +
+ @endforeach +
+
+ @endif + + @if ($removed !== []) + +
+ @foreach ($removed as $row) + @php + $name = (string) ($row['name'] ?? 'Unknown'); + $id = (string) ($row['id'] ?? ''); + @endphp + +
+
{{ $name }}
+ @if ($id !== '') +
{{ $id }}
+ @endif +
+ @endforeach +
+
+ @endif + + @if ($current !== []) + +
+ @foreach ($current as $row) + @php + $name = (string) ($row['name'] ?? 'Unknown'); + $id = (string) ($row['id'] ?? ''); + @endphp + +
+
{{ $name }}
+ @if ($id !== '') +
{{ $id }}
+ @endif +
+ @endforeach +
+
+ @endif + + @if ($baseline !== []) + +
+ @foreach ($baseline as $row) + @php + $name = (string) ($row['name'] ?? 'Unknown'); + $id = (string) ($row['id'] ?? ''); + @endphp + +
+
{{ $name }}
+ @if ($id !== '') +
{{ $id }}
+ @endif +
+ @endforeach +
+
+ @endif +
diff --git a/resources/views/filament/pages/drift-landing.blade.php b/resources/views/filament/pages/drift-landing.blade.php index 9b631d5..d1703ed 100644 --- a/resources/views/filament/pages/drift-landing.blade.php +++ b/resources/views/filament/pages/drift-landing.blade.php @@ -5,6 +5,98 @@ Review new drift findings between the last two inventory sync runs for the current scope.
+ @if (filled($scopeKey)) +
+ Scope: {{ $scopeKey }} + @if ($baselineRunId && $currentRunId) + · Baseline + @if ($this->getBaselineRunUrl()) + + #{{ $baselineRunId }} + + @else + #{{ $baselineRunId }} + @endif + @if (filled($baselineFinishedAt)) + ({{ $baselineFinishedAt }}) + @endif + · Current + @if ($this->getCurrentRunUrl()) + + #{{ $currentRunId }} + + @else + #{{ $currentRunId }} + @endif + @if (filled($currentFinishedAt)) + ({{ $currentFinishedAt }}) + @endif + @endif +
+ @endif + + @if ($state === 'blocked') + + Blocked + + + @if (filled($message)) +
+ {{ $message }} +
+ @endif + @elseif ($state === 'generating') + + Generating + + +
+ Drift generation has been queued. Refresh this page once it finishes. +
+ + @if ($this->getBulkRunUrl()) + + @endif + @elseif ($state === 'error') + + Error + + + @if (filled($message)) +
+ {{ $message }} +
+ @endif + + @if ($this->getBulkRunUrl()) + + @endif + @elseif ($state === 'ready') +
+ + New: {{ (int) ($statusCounts['new'] ?? 0) }} + +
+ + @if (filled($message)) +
+ {{ $message }} +
+ @endif + @else + + Ready + + @endif +
Findings diff --git a/specs/044-drift-mvp/checklists/requirements.md b/specs/044-drift-mvp/checklists/requirements.md index 04dab55..ceb096c 100644 --- a/specs/044-drift-mvp/checklists/requirements.md +++ b/specs/044-drift-mvp/checklists/requirements.md @@ -6,28 +6,28 @@ # Specification Quality Checklist: Drift MVP (044) ## Content Quality -- [ ] No implementation details (languages, frameworks, APIs) (T002) -- [x] Focused on user value and business needs (spec.md: Purpose, User Scenarios & Testing, Success Criteria) -- [ ] Written for non-technical stakeholders (T002) -- [x] All mandatory sections completed (spec.md includes Purpose, Scenarios, FR/NFR, Success Criteria, Out of Scope) +- [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: FR1–FR4; tasks.md defines tests for key behaviors T015–T018, T024–T025, T029–T030, T035, T038) -- [x] Success criteria are measurable (spec.md: SC1 "under 3 minutes", SC2 deterministic consistency) -- [x] Success criteria are technology-agnostic (no implementation details) (spec.md: SC1–SC2) +- [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: <2 runs blocked state; generation failure explicit error state; acknowledgement per comparison) -- [x] Scope is clearly bounded (spec.md: FR2b + Out of Scope) -- [x] Dependencies and assumptions identified (spec.md: Dependencies / Name Resolution; NFR2; "No render-time Graph calls") +- [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: FR1–FR4 + Scenario 1/2/3) +- [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) -- [ ] Feature meets measurable outcomes defined in Success Criteria (T022, T023, T026, T027, T031, T033, T035) -- [ ] No implementation details leak into specification (T002) +- [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 diff --git a/specs/044-drift-mvp/contracts/admin-findings.openapi.yaml b/specs/044-drift-mvp/contracts/admin-findings.openapi.yaml index 3c742bd..7d20e3c 100644 --- a/specs/044-drift-mvp/contracts/admin-findings.openapi.yaml +++ b/specs/044-drift-mvp/contracts/admin-findings.openapi.yaml @@ -102,9 +102,30 @@ paths: 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: diff --git a/specs/044-drift-mvp/plan.md b/specs/044-drift-mvp/plan.md index 81fe07e..a448d66 100644 --- a/specs/044-drift-mvp/plan.md +++ b/specs/044-drift-mvp/plan.md @@ -11,6 +11,7 @@ ## Summary - 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. ## Technical Context @@ -28,6 +29,7 @@ ## Technical Context - 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 diff --git a/specs/044-drift-mvp/quickstart.md b/specs/044-drift-mvp/quickstart.md index 2e709a3..b38df8b 100644 --- a/specs/044-drift-mvp/quickstart.md +++ b/specs/044-drift-mvp/quickstart.md @@ -15,14 +15,16 @@ ## Prepare data ## Use Drift 1. Navigate to the new Drift area. -2. On first open, Drift will dispatch a background job to generate findings for: +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` -3. Refresh the page once the job finishes. +4. Refresh the page once generation finishes. ## Triage -- Acknowledge a finding; it should move out of the “new” view but remain visible/auditable. +- Acknowledge a finding; it moves out of the default “new” view but remains visible/auditable. +- Use the status filter to include acknowledged findings. ## Notes diff --git a/specs/044-drift-mvp/spec.md b/specs/044-drift-mvp/spec.md index a3a2a61..e6c04e0 100644 --- a/specs/044-drift-mvp/spec.md +++ b/specs/044-drift-mvp/spec.md @@ -1,166 +1,81 @@ -# Feature Specification: Drift MVP - -**Feature Branch**: `feat/044-drift-mvp` -**Created**: 2026-01-07 -**Status**: Draft +# Feature Specification: Drift MVP (044) ## 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. -## Clarifications - -### Session 2026-01-12 - -- Q: How should Drift pick the baseline run for a given tenant + scope? → A: Baseline = previous successful inventory run for the same scope; compare against the latest successful run. -- Q: Should Drift findings be persisted or computed on demand? → A: Persist findings in DB per comparison (baseline_run_id + current_run_id), including a deterministic fingerprint for stable identity + triage. -- Q: How define the fingerprint (Stable ID) for a drift finding? → A: `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)` (normalized; excludes volatile fields). -- Q: Which inventory entities/types are in scope for Drift MVP? → A: Policies + Assignments. -- Q: When should drift findings be generated? → A: On-demand when opening Drift: if findings for (baseline,current,scope) don’t exist yet, dispatch an async job to generate them. - -### Session 2026-01-13 - -- Q: What should Drift do if there are fewer than two successful inventory runs for the same `scope_key`? → A: Show a blocked/empty state (“Need at least 2 successful runs for this scope to calculate drift”) and do not dispatch drift generation. -- Q: Should acknowledgement carry forward across comparisons? → A: No; acknowledgement is per comparison (`baseline_run_id` + `current_run_id` + `scope_key`). The same drift may re-appear as `new` in later comparisons. -- Q: Which `change_type` values are supported in Drift MVP? → A: `added`, `removed`, `modified` (assignment target/intent changes are covered under `modified`). -- Q: What is the default UI behavior for `new` vs `acknowledged` findings? → A: Default UI shows only `new`; `acknowledged` is accessible via an explicit filter. -- Q: What should the UI do if drift generation fails for a comparison? → A: Show an explicit error state (safe message + reference/run ids) and do not show findings for that comparison until a successful generation exists. - -### Session 2026-01-14 - -- Q: How should Drift track generation status/errors/idempotency for a comparison? → A: Use `BulkOperationRun` as the canonical run container (status, failures, idempotency_key, and consistent UI/ops patterns). - -## Pinned Decisions (MVP defaults) - -- Drift is implemented as a generator that writes persisted Finding rows (not only an in-memory/on-demand diff). -- Baseline selection: baseline = previous successful inventory run for the same scope_key; comparison = latest successful inventory run for the same scope_key. -- Scope is first-class via `scope_key` and must be deterministic to support future pinned baselines and compare workflows. -- Fingerprints are deterministic and stable for triage/audit workflows. -- Drift MVP only uses `finding_type=drift` and `status` in {`new`, `acknowledged`}. -- Default severity: `medium` (until a rule engine exists). -- UI must not perform render-time Graph calls. Graph access (if any) is limited to background sync/jobs. -- Drift generation is tracked via `BulkOperationRun` to persist status/errors across refresh and to enforce idempotency per (tenant, scope_key, baseline_run_id, current_run_id). - -## Key Entities / Generic Findings (Future-proof) - -### Finding (generic) - -We want Drift MVP to remain MVP-sized, while making it easy to add future generators (Security Suite Audits, Cross-tenant Compare) without inventing a new model. - -Rationale: -- Drift = delta engine over runs. -- Audit = rule engine over inventory. -- Both write Findings with the same semantics: deterministic fingerprint + triage + minimized evidence. - -- `finding_type` (enum): `drift` (MVP), later `audit`, `compare` -- `tenant_id` -- `scope_key` (string): deterministic scope identifier (see Scope Definition / FR1) -- `baseline_run_id` (nullable; e.g. audit/compare) -- `current_run_id` (nullable; e.g. audit) -- `fingerprint` (string): deterministic; unique per tenant+scope+subject+change -- `subject_type` (string): e.g. policy type (or other inventory entity type) -- `subject_external_id` (string): Graph external id -- `severity` (enum): `low` / `medium` / `high` (MVP default: `medium`) -- `status` (enum): `new` / `acknowledged` (later: `snoozed` / `assigned` / `commented`) -- `acknowledged_at` (nullable) -- `acknowledged_by_user_id` (nullable) -- `evidence_jsonb` (jsonb): sanitized, small, secrets-free (no raw payload dumps) -- Optional/nullable for later (prepared; out of MVP): `rule_id`, `control_id`, `expected_value`, `source` - -MVP implementation scope: only `finding_type=drift`, statuses `new/acknowledged`, and no rule engine. - -## User Scenarios & Testing +## User Scenarios ### Scenario 1: View drift summary -- Given inventory sync has run at least twice -- When the admin opens Drift -- Then they see a summary of changes since the last baseline -- If there are fewer than two successful runs for the same `scope_key`, Drift shows a blocked/empty state and does not start drift generation. +- Given the system has at least two successful inventory snapshots for the same selection/scope +- 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 + +- 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 -- When the admin opens the finding -- Then they see what changed, when, and which run observed it +- When an admin acknowledges it +- Then it no longer appears in “new” views, but remains available for audit/history -### Scenario 3: Acknowledge/triage -- Given a drift finding exists -- When the admin marks it acknowledged -- Then it is hidden from “new” lists but remains auditable +## Rules -- Acknowledgement is per comparison; later comparisons may still surface the same drift as `new`. +### Coverage (MVP) -## Functional Requirements +- Drift findings cover **policies, their assignments, and scope tags** for the selected scope. -- FR1: Baseline + scope - - Define `scope_key` as the deterministic Inventory selection identifier. - - MVP definition: `scope_key = InventorySyncRun.selection_hash`. - - Rationale: selection hashing already normalizes equivalent selections; reusing it keeps drift scope stable and consistent across the product. - - Baseline run (MVP) = previous successful inventory run for the same `scope_key`. - - Comparison run (MVP) = latest successful inventory run for the same `scope_key`. +### Baseline and comparison selection -- FR2: Finding generation (Drift MVP) - - Findings are persisted per (`baseline_run_id`, `current_run_id`, `scope_key`). - - Findings cover adds, removals, and changes for supported entities (Policies + Assignments). - - MVP `change_type` values: `added`, `removed`, `modified`. - - Findings are deterministic: same baseline/current + scope_key ⇒ same set of fingerprints. - - Drift generation must be tracked via `BulkOperationRun` with an idempotency key derived from (tenant_id, scope_key, baseline_run_id, current_run_id). - - If fewer than two successful inventory runs exist for a given `scope_key`, Drift does not generate findings and must surface a clear blocked/empty state in the UI. +- Drift always compares two successful inventory snapshots for the same selection/scope. +- The “current” snapshot is the latest successful snapshot for that scope. +- The “baseline” snapshot is the previous successful snapshot for that scope. -- FR2a: Fingerprint definition (MVP) - - Fingerprint = `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)`. - - `baseline_hash` / `current_hash` are hashes over normalized, sanitized comparison data (exclude volatile fields like timestamps). - - Goal: stable identity for triage + audit compatibility. +### Change types -- FR2b: Drift MVP scope includes Policies and their Assignments. - - Assignment drift includes target changes (e.g., groupId) and intent changes. +Each drift finding must be categorized as one of: -- FR3: Provide Drift UI with summary and details. - - Default lists and the Drift landing summary show only `status=new` by default. - - The UI must provide a filter to include `acknowledged` findings. - - If drift generation fails for a comparison, the UI must surface an explicit error state (no secrets), including reference identifiers (e.g., run ids and the `BulkOperationRun` id), and must not fall back to stale/previous results. +- **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) -- FR4: Triage (MVP) - - Admin can acknowledge a finding; record `acknowledged_by_user_id` + `acknowledged_at`. - - Acknowledgement does not carry forward across comparisons in the MVP. - - Findings are never deleted in the MVP. +### Acknowledgement -## Non-Functional Requirements +- Acknowledgement is **per comparison** (baseline + current within a scope). +- Acknowledgement does **not** carry forward to later comparisons. -- NFR1: Drift generation must be deterministic for the same baseline and scope. -- NFR2: Drift must remain tenant-scoped and safe to display. -- NFR3: Evidence minimization - - `evidence_jsonb` must be sanitized (no tokens/secrets) and kept small. - - MVP drift evidence should include only: - - `change_type` - - changed_fields / metadata summary (counts, field list) - - run refs (baseline_run_id/current_run_id, timestamps) - - No raw payload dumps. +### UI states -## Dependencies / Name Resolution +- **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. -- Drift/Audit UI should resolve labels via Inventory + Foundations (047) + Groups Cache (051) where applicable. -- No render-time Graph calls (Graph only in background sync/jobs, never in UI render). +### Default views -## Success Criteria +- Default Drift summary and default finding lists show **new** findings only. +- Acknowledged findings are accessible via an explicit filter. -- SC1: Admins can identify drift across supported types (Policies + Assignments) in under 3 minutes. -- SC2: Drift results are consistent across repeated generation for the same baseline. +### Run tracking (status, errors, idempotency) -## Out of Scope +- 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). -- Automatic revert/promotion. -- Rule engine in MVP (Audit later), but the data model is prepared via `rule_id` / `control_id` / `expected_value`. +### Determinism and stable identity -## Future Work (non-MVP) +- 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. -- Security Suite Audits: add rule-based generators that write Findings (no new Finding model). -- Cross-tenant Compare: may write Findings (`finding_type=compare`) or emit a compatible format that can be stored as Findings. +## Acceptance Criteria -## Related Specs - -- Program: `specs/039-inventory-program/spec.md` -- Core: `specs/040-inventory-core/spec.md` -- Compare: `specs/043-cross-tenant-compare-and-promotion/spec.md` +- 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. diff --git a/specs/044-drift-mvp/tasks.md b/specs/044-drift-mvp/tasks.md index 481f990..f6dd823 100644 --- a/specs/044-drift-mvp/tasks.md +++ b/specs/044-drift-mvp/tasks.md @@ -25,10 +25,10 @@ ## Phase 1: Setup (Shared Infrastructure) **Purpose**: Project wiring for Drift MVP. -- [ ] T001 Create/update constitution gate checklist in `specs/044-drift-mvp/checklists/requirements.md` -- [ ] 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}` -- [ ] T003 Add Drift landing page shell in `app/Filament/Pages/DriftLanding.php` -- [ ] T004 [P] Add Finding resource shells in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/` +- [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/` --- @@ -38,17 +38,17 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Checkpoint**: DB schema exists, tenant scoping enforced, and tests can create Finding rows. -- [ ] T005 Create `findings` migration in `database/migrations/*_create_findings_table.php` with Finding fields aligned to `specs/044-drift-mvp/spec.md`: +- [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) -- [ ] T006 Create `Finding` model in `app/Models/Finding.php` (casts for `evidence_jsonb`, enums/constants for `finding_type`/`severity`/`status`, `acknowledged_at` handling) -- [ ] T007 [P] Add `FindingFactory` in `database/factories/FindingFactory.php` -- [ ] T008 Ensure `InventorySyncRunFactory` exists (or add it) in `database/factories/InventorySyncRunFactory.php` -- [ ] T009 Add authorization policy in `app/Policies/FindingPolicy.php` and wire it in `app/Providers/AuthServiceProvider.php` (or project equivalent) -- [ ] T010 Add Drift permissions in `config/intune_permissions.php` (view + acknowledge) and wire them into Filament navigation/actions -- [ ] T011 Enforce tenant scoping for Finding queries in `app/Models/Finding.php` and/or `app/Filament/Resources/FindingResource.php` -- [ ] T012 Implement fingerprint helper in `app/Services/Drift/DriftHasher.php` (sha256 per spec) -- [ ] T013 Pin `scope_key = InventorySyncRun.selection_hash` in `app/Services/Drift/DriftScopeKey.php` (single canonical definition) -- [ ] T014 Implement evidence allowlist builder in `app/Services/Drift/DriftEvidence.php` (new file) +- [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) --- @@ -60,18 +60,18 @@ ## Phase 3: User Story 1 - View drift summary (Priority: P1) MVP ### Tests (write first) -- [ ] T015 [P] [US1] Baseline selection tests in `tests/Feature/Drift/DriftBaselineSelectionTest.php` -- [ ] T016 [P] [US1] Generation dispatch tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php` -- [ ] T017 [P] [US1] Tenant isolation tests in `tests/Feature/Drift/DriftTenantIsolationTest.php` -- [ ] T018 [P] [US1] Assignment drift detection test (targets + intent changes per FR2b) in `tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php` +- [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 -- [ ] T019 [US1] Implement run selection service in `app/Services/Drift/DriftRunSelector.php` -- [ ] T020 [US1] Implement generator job in `app/Jobs/GenerateDriftFindingsJob.php` (dedupe/lock by tenant+scope+baseline+current) -- [ ] T021 [US1] Implement generator service in `app/Services/Drift/DriftFindingGenerator.php` (idempotent) -- [ ] T022 [US1] Implement landing behavior (dispatch + status UI) in `app/Filament/Pages/DriftLanding.php` -- [ ] T023 [US1] Implement summary queries/widgets in `app/Filament/Pages/DriftLanding.php` +- [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` --- @@ -83,14 +83,17 @@ ## Phase 4: User Story 2 - Drill into a drift finding (Priority: P2) ### Tests (write first) -- [ ] T024 [P] [US2] Finding detail test in `tests/Feature/Drift/DriftFindingDetailTest.php` -- [ ] T025 [P] [US2] Evidence minimization test in `tests/Feature/Drift/DriftEvidenceMinimizationTest.php` +- [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 -- [ ] T026 [US2] Implement list UI in `app/Filament/Resources/FindingResource.php` (filters: status, scope_key, run) -- [ ] T027 [US2] Implement detail UI in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php` -- [ ] T028 [US2] Implement DB-only name resolution in `app/Filament/Resources/FindingResource.php` (inventory/foundations caches) +- [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` --- @@ -102,25 +105,57 @@ ## Phase 5: User Story 3 - Acknowledge/triage (Priority: P3) ### Tests (write first) -- [ ] T029 [P] [US3] Acknowledge action test in `tests/Feature/Drift/DriftAcknowledgeTest.php` -- [ ] T030 [P] [US3] Acknowledge authorization test in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php` +- [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 -- [ ] T031 [US3] Add acknowledge actions in `app/Filament/Resources/FindingResource.php` -- [ ] T032 [US3] Implement `acknowledge()` domain method in `app/Models/Finding.php` -- [ ] T033 [US3] Ensure Drift summary excludes acknowledged by default in `app/Filament/Pages/DriftLanding.php` +- [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 -- [ ] 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) -- [ ] T035 [P] Add determinism test in `tests/Feature/Drift/DriftGenerationDeterminismTest.php` (same baseline/current => same fingerprints) -- [ ] T036 Add job observability logs in `app/Jobs/GenerateDriftFindingsJob.php` (no secrets) -- [ ] T037 Add idempotency/upsert strategy in `app/Services/Drift/DriftFindingGenerator.php` -- [ ] T038 Ensure volatile fields excluded from hashing in `app/Services/Drift/DriftHasher.php` and cover in `tests/Feature/Drift/DriftHasherTest.php` -- [ ] T039 Validate and update `specs/044-drift-mvp/quickstart.md` after implementation +- [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` --- diff --git a/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php b/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php new file mode 100644 index 0000000..c687664 --- /dev/null +++ b/tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php @@ -0,0 +1,31 @@ +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); +}); diff --git a/tests/Feature/Drift/DriftAcknowledgeTest.php b/tests/Feature/Drift/DriftAcknowledgeTest.php new file mode 100644 index 0000000..05f8784 --- /dev/null +++ b/tests/Feature/Drift/DriftAcknowledgeTest.php @@ -0,0 +1,28 @@ +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()); +}); diff --git a/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php index 5a186ea..dee9bc6 100644 --- a/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php +++ b/tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php @@ -52,7 +52,6 @@ 'policy_type' => $policy->policy_type, 'captured_at' => $baseline->finished_at->copy()->subMinute(), 'assignments' => $baselineAssignments, - 'assignments_hash' => hash('sha256', json_encode($baselineAssignments)), ]); PolicyVersion::factory()->for($tenant)->for($policy)->create([ @@ -60,7 +59,6 @@ 'policy_type' => $policy->policy_type, 'captured_at' => $current->finished_at->copy()->subMinute(), 'assignments' => $currentAssignments, - 'assignments_hash' => hash('sha256', json_encode($currentAssignments)), ]); $generator = app(DriftFindingGenerator::class); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php new file mode 100644 index 0000000..936ff40 --- /dev/null +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php @@ -0,0 +1,34 @@ +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)); +}); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php new file mode 100644 index 0000000..b283d35 --- /dev/null +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php @@ -0,0 +1,51 @@ +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(); +}); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php new file mode 100644 index 0000000..4903049 --- /dev/null +++ b/tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php @@ -0,0 +1,60 @@ +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)); +}); diff --git a/tests/Feature/Drift/DriftBulkAcknowledgeTest.php b/tests/Feature/Drift/DriftBulkAcknowledgeTest.php new file mode 100644 index 0000000..ea613bf --- /dev/null +++ b/tests/Feature/Drift/DriftBulkAcknowledgeTest.php @@ -0,0 +1,34 @@ +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()); + }); +}); diff --git a/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php b/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php new file mode 100644 index 0000000..f8832aa --- /dev/null +++ b/tests/Feature/Drift/DriftCompletedRunWithZeroFindingsTest.php @@ -0,0 +1,71 @@ +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); +}); diff --git a/tests/Feature/Drift/DriftEvidenceMinimizationTest.php b/tests/Feature/Drift/DriftEvidenceMinimizationTest.php new file mode 100644 index 0000000..82bf47b --- /dev/null +++ b/tests/Feature/Drift/DriftEvidenceMinimizationTest.php @@ -0,0 +1,24 @@ + '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'); +}); diff --git a/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php new file mode 100644 index 0000000..605617c --- /dev/null +++ b/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php @@ -0,0 +1,151 @@ +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'); +}); diff --git a/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php new file mode 100644 index 0000000..3d905d3 --- /dev/null +++ b/tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php @@ -0,0 +1,100 @@ +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'); +}); diff --git a/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php new file mode 100644 index 0000000..ffc4136 --- /dev/null +++ b/tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php @@ -0,0 +1,100 @@ +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'); +}); diff --git a/tests/Feature/Drift/DriftFindingDetailTest.php b/tests/Feature/Drift/DriftFindingDetailTest.php new file mode 100644 index 0000000..b216dc4 --- /dev/null +++ b/tests/Feature/Drift/DriftFindingDetailTest.php @@ -0,0 +1,48 @@ +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); +}); diff --git a/tests/Feature/Drift/DriftGenerationDeterminismTest.php b/tests/Feature/Drift/DriftGenerationDeterminismTest.php new file mode 100644 index 0000000..c5ea172 --- /dev/null +++ b/tests/Feature/Drift/DriftGenerationDeterminismTest.php @@ -0,0 +1,76 @@ +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); +}); diff --git a/tests/Feature/Drift/DriftGenerationDispatchTest.php b/tests/Feature/Drift/DriftGenerationDispatchTest.php index 3e54f9a..09fc6fa 100644 --- a/tests/Feature/Drift/DriftGenerationDispatchTest.php +++ b/tests/Feature/Drift/DriftGenerationDispatchTest.php @@ -2,7 +2,9 @@ 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; @@ -30,15 +32,81 @@ Livewire::test(DriftLanding::class); - Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey): bool { + $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->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(); @@ -57,4 +125,5 @@ Livewire::test(DriftLanding::class); Queue::assertNothingPushed(); + expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); }); diff --git a/tests/Feature/Drift/DriftHasherTest.php b/tests/Feature/Drift/DriftHasherTest.php new file mode 100644 index 0000000..2d39eeb --- /dev/null +++ b/tests/Feature/Drift/DriftHasherTest.php @@ -0,0 +1,39 @@ + '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)); +}); diff --git a/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php b/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php new file mode 100644 index 0000000..3927771 --- /dev/null +++ b/tests/Feature/Drift/DriftLandingShowsComparisonInfoTest.php @@ -0,0 +1,33 @@ +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()); +}); diff --git a/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php b/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php new file mode 100644 index 0000000..34ecbf3 --- /dev/null +++ b/tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php @@ -0,0 +1,73 @@ +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'); +}); diff --git a/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php b/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php new file mode 100644 index 0000000..539a343 --- /dev/null +++ b/tests/Feature/Drift/DriftPolicySnapshotMetadataOnlyDoesNotCreateFindingTest.php @@ -0,0 +1,62 @@ +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); +}); diff --git a/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php b/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php new file mode 100644 index 0000000..15b8b8f --- /dev/null +++ b/tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php @@ -0,0 +1,84 @@ +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'); +}); diff --git a/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php b/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php new file mode 100644 index 0000000..be29a5a --- /dev/null +++ b/tests/Feature/Drift/DriftScopeTagLegacyDefaultDoesNotCreateFindingTest.php @@ -0,0 +1,69 @@ +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); +});