From 61b0b1bc239ee12c6f994d036258162c5318884c Mon Sep 17 00:00:00 2001 From: ahmido Date: Tue, 30 Dec 2025 01:50:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(010):=20Administrative=20Templates=20?= =?UTF-8?q?=E2=80=93=20restore=20from=20PolicyVersion=20+=20version=20visi?= =?UTF-8?q?bility=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Restore nutzt bisher den Snapshot aus dem BackupSet (BackupItem). Wenn der Snapshot “unvollständig”/nicht der gewünschte Stand ist, landen nach Restore nur wenige Admin-Template-Settings in Intune. Lösung: Neue Action “Restore to Intune” direkt an einer konkreten PolicyVersion (inkl. Dry-Run Toggle) → reproduzierbarer Rollback auf exakt diese Version. Restore-UI zeigt jetzt PolicyVersion-Nummer (version: X) in der Item-Auswahl + BackupSet Items Tabelle hat eine Version-Spalte. Implementierung: RestoreService::executeFromPolicyVersion() erzeugt dafür einen kleinen, temporären BackupSet+BackupItem aus der Version und startet einen normalen RestoreRun. Pest-Test: PolicyVersionRestoreToIntuneTest.php Specs/TODO: Offene Follow-ups sind dokumentiert in tasks.md unter “Open TODOs (Follow-up)”. QA (GUI): Inventory → Policies → → Versions → Restore to Intune (erst Dry-Run, dann Execute) Backups & Restore → Restore Runs → Create (bei Items steht version: X) Backups & Restore → Backup Sets → (Version-Spalte) Tests: PolicyVersionRestoreToIntuneTest.php Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/13 --- .../BackupItemsRelationManager.php | 5 + .../VersionsRelationManager.php | 55 +++ app/Filament/Resources/RestoreRunResource.php | 4 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Graph/GraphContractRegistry.php | 21 + .../GroupPolicyConfigurationNormalizer.php | 181 +++++++ app/Services/Intune/PolicySnapshotService.php | 180 +++++++ app/Services/Intune/RestoreService.php | 448 +++++++++++++++++- .../SettingsCatalogPolicyNormalizer.php | 117 ++++- config/graph_contracts.php | 20 + ...olicy-version-assignments-widget.blade.php | 31 +- specs/010-admin-templates/plan.md | 22 + specs/010-admin-templates/spec.md | 52 ++ specs/010-admin-templates/tasks.md | 29 ++ .../GroupPolicyConfigurationHydrationTest.php | 144 ++++++ ...pPolicyConfigurationNormalizedDiffTest.php | 29 ++ .../GroupPolicyConfigurationRestoreTest.php | 183 +++++++ .../PolicyVersionRestoreToIntuneTest.php | 153 ++++++ .../PolicyVersionScopeTagsDisplayTest.php | 59 +++ .../Feature/Filament/RestoreExecutionTest.php | 29 +- ...ettingsCatalogPolicyNormalizedDiffTest.php | 55 +++ 21 files changed, 1799 insertions(+), 20 deletions(-) create mode 100644 app/Services/Intune/GroupPolicyConfigurationNormalizer.php create mode 100644 specs/010-admin-templates/plan.md create mode 100644 specs/010-admin-templates/spec.md create mode 100644 specs/010-admin-templates/tasks.md create mode 100644 tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php create mode 100644 tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php create mode 100644 tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php create mode 100644 tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php create mode 100644 tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php create mode 100644 tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index dd20cf2..eddf393 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -30,6 +30,11 @@ public function table(Table $table): Table ->sortable() ->searchable() ->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()), + Tables\Columns\TextColumn::make('policyVersion.version_number') + ->label('Version') + ->badge() + ->default('—') + ->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number), Tables\Columns\TextColumn::make('policy_type') ->label('Type') ->badge() diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 84a7560..56f42cc 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -2,7 +2,13 @@ namespace App\Filament\Resources\PolicyResource\RelationManagers; +use App\Filament\Resources\RestoreRunResource; +use App\Models\PolicyVersion; +use App\Models\Tenant; +use App\Services\Intune\RestoreService; use Filament\Actions; +use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -24,6 +30,55 @@ public function table(Table $table): Table ->filters([]) ->headerActions([]) ->actions([ + Actions\Action::make('restore_to_intune') + ->label('Restore to Intune') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") + ->modalSubheading('Creates a restore run using this policy version snapshot.') + ->form([ + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + ]) + ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { + $tenant = Tenant::current(); + + if ($record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + try { + $run = $restoreService->executeFromPolicyVersion( + tenant: $tenant, + version: $record, + dryRun: (bool) ($data['is_dry_run'] ?? true), + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Restore run started') + ->success() + ->send(); + + return redirect(RestoreRunResource::getUrl('view', ['record' => $run])); + }), Actions\ViewAction::make() ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index bbc4d25..3f7088d 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -548,7 +548,7 @@ private static function restoreItemOptionData(?int $backupSetId): array ->orWhereDoesntHave('policy') ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); }) - ->with('policy:id,display_name') + ->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at']) ->get() ->sortBy(function (BackupItem $item) { $meta = static::typeMeta($item->policy_type); @@ -570,6 +570,7 @@ private static function restoreItemOptionData(?int $backupSetId): array $platform = $item->platform ?? $meta['platform'] ?? null; $displayName = $item->resolvedDisplayName(); $identifier = $item->policy_identifier ?? null; + $versionNumber = $item->policyVersion?->version_number; $options[$item->id] = $displayName; @@ -578,6 +579,7 @@ private static function restoreItemOptionData(?int $backupSetId): array $typeLabel, $platform, "restore: {$restore}", + $versionNumber ? "version: {$versionNumber}" : null, $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, $identifier ? 'id: '.Str::limit($identifier, 24, '...') : null, ]); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e654c93..517a762 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use Illuminate\Support\ServiceProvider; @@ -37,6 +38,7 @@ public function register(): void AppProtectionPolicyNormalizer::class, CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, + GroupPolicyConfigurationNormalizer::class, SettingsCatalogPolicyNormalizer::class, ], 'policy-type-normalizers' diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index abf23f0..9d07ff4 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -108,6 +108,27 @@ public function subresourceSettingsPath(string $policyType, string $policyId): ? return str_replace('{id}', urlencode($policyId), $path); } + public function subresourcePath(string $policyType, string $subresourceKey, array $replacements = []): ?string + { + $subresources = config("graph_contracts.types.$policyType.subresources", []); + $subresource = $subresources[$subresourceKey] ?? null; + $path = is_array($subresource) ? ($subresource['path'] ?? null) : null; + + if (! is_string($path) || $path === '') { + return null; + } + + foreach ($replacements as $key => $value) { + if (! is_string($key) || $key === '') { + continue; + } + + $path = str_replace($key, urlencode((string) $value), $path); + } + + return $path; + } + public function settingsWriteMethod(string $policyType): ?string { $contract = $this->get($policyType); diff --git a/app/Services/Intune/GroupPolicyConfigurationNormalizer.php b/app/Services/Intune/GroupPolicyConfigurationNormalizer.php new file mode 100644 index 0000000..f54f058 --- /dev/null +++ b/app/Services/Intune/GroupPolicyConfigurationNormalizer.php @@ -0,0 +1,181 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $definitionValues = $snapshot['definitionValues'] ?? null; + $snapshot = Arr::except($snapshot, ['definitionValues']); + + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if (! is_array($definitionValues) || $definitionValues === []) { + $normalized['warnings'] = array_values(array_unique(array_merge( + $normalized['warnings'] ?? [], + ['Administrative Template settings not hydrated for this policy.'] + ))); + + return $normalized; + } + + $rows = []; + + foreach ($definitionValues as $index => $definitionValue) { + if (! is_array($definitionValue)) { + continue; + } + + $definition = $definitionValue['#Definition_displayName'] ?? null; + $definitionId = $definitionValue['#Definition_Id'] ?? null; + $category = $definitionValue['#Definition_categoryPath'] ?? '-'; + $enabled = (bool) ($definitionValue['enabled'] ?? false); + $path = $this->buildDiffPath( + definition: $definition, + definitionId: $definitionId, + categoryPath: $category, + index: $index, + ); + + $value = $this->formatGroupPolicyValue($definitionValue, $enabled); + $dataType = $this->inferGroupPolicyDataType($definitionValue); + + $rows[] = [ + 'definition' => is_string($definition) && $definition !== '' ? $definition : 'Definition', + 'definition_id' => is_string($definitionId) ? $definitionId : null, + 'category' => is_string($category) && $category !== '' ? $category : '-', + 'data_type' => $dataType, + 'value' => $value, + 'description' => '-', + 'path' => $path, + 'raw' => $definitionValue, + ]; + } + + if ($rows !== []) { + $normalized['settings_table'] = [ + 'title' => 'Administrative Template settings', + 'rows' => $rows, + ]; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function inferGroupPolicyDataType(array $definitionValue): string + { + $presentationValues = $definitionValue['presentationValues'] ?? null; + + if (! is_array($presentationValues) || $presentationValues === []) { + return 'Boolean'; + } + + foreach ($presentationValues as $presentationValue) { + if (! is_array($presentationValue)) { + continue; + } + + if (array_key_exists('values', $presentationValue)) { + return 'Choice'; + } + + if (array_key_exists('value', $presentationValue)) { + $value = $presentationValue['value']; + + if (is_bool($value)) { + return 'Boolean'; + } + + if (is_int($value) || is_float($value) || is_numeric($value)) { + return 'Number'; + } + + return 'Text'; + } + } + + return 'Text'; + } + + private function formatGroupPolicyValue(array $definitionValue, bool $enabled): string + { + $presentationValues = $definitionValue['presentationValues'] ?? null; + + if (! is_array($presentationValues) || $presentationValues === []) { + return $enabled ? 'Enabled' : 'Disabled'; + } + + $parts = []; + + foreach ($presentationValues as $presentationValue) { + if (! is_array($presentationValue)) { + continue; + } + + $label = $presentationValue['#Presentation_Label'] ?? null; + $value = $presentationValue['value'] ?? null; + $values = $presentationValue['values'] ?? null; + + $valueString = match (true) { + is_array($values) => json_encode($values), + is_bool($value) => $value ? 'true' : 'false', + is_scalar($value) => (string) $value, + default => null, + }; + + if ($valueString === null) { + $clean = Arr::except($presentationValue, ['presentation@odata.bind', '#Presentation_Label', '#Presentation_Id']); + $valueString = $clean !== [] ? json_encode($clean) : null; + } + + if (is_string($label) && $label !== '') { + $parts[] = $label.': '.($valueString ?? '-'); + } else { + $parts[] = $valueString ?? '-'; + } + } + + return implode(' | ', array_values(array_filter($parts, static fn ($part) => $part !== ''))); + } + + private function buildDiffPath(mixed $definition, mixed $definitionId, mixed $categoryPath, int $index): string + { + $label = is_string($definition) && $definition !== '' ? $definition : "definitionValues[{$index}]"; + + if (is_string($definitionId) && $definitionId !== '') { + $label .= " ({$definitionId})"; + } + + if (is_string($categoryPath) && $categoryPath !== '' && $categoryPath !== '-') { + return $categoryPath.' > '.$label; + } + + return $label; + } +} diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 55ccd51..c173b1b 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -87,6 +87,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } + if ($policy->policy_type === 'groupPolicyConfiguration') { + [$payload, $metadata] = $this->hydrateGroupPolicyConfiguration( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + policyId: $policy->external_id, + payload: is_array($payload) ? $payload : [], + metadata: $metadata + ); + } + if ($policy->policy_type === 'deviceCompliancePolicy') { [$payload, $metadata] = $this->hydrateComplianceActions( tenantIdentifier: $tenantIdentifier, @@ -251,6 +261,176 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant return [$payload, $metadata]; } + /** + * Hydrate Administrative Templates (Group Policy Configurations) with definitionValues and presentationValues. + * + * @return array{0:array,1:array} + */ + private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + { + $strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration'); + $definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [ + '{id}' => $policyId, + ]); + + if ($strategy !== 'subresource_definition_values' || ! $definitionValuesPath) { + return [$payload, $metadata]; + } + + $graphBase = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim((string) config('graph.version', 'beta'), '/'); + $definitionValues = []; + $nextPath = $definitionValuesPath; + $hydrationStatus = 'complete'; + + while ($nextPath) { + $response = $this->graphClient->request('GET', $nextPath, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]); + + if ($response->failed()) { + $hydrationStatus = 'failed'; + break; + } + + $definitionValues = array_merge($definitionValues, $response->data['value'] ?? []); + $nextLink = $response->data['@odata.nextLink'] ?? null; + + if (! $nextLink) { + break; + } + + $nextPath = $this->stripGraphBaseUrl((string) $nextLink); + } + + if ($hydrationStatus === 'failed') { + $metadata['warnings'] = array_values(array_unique(array_merge( + $metadata['warnings'] ?? [], + ['Hydration failed: could not load Administrative Templates definition values.'] + ))); + + return [$payload, $metadata]; + } + + $settings = []; + + foreach ($definitionValues as $definitionValue) { + if (! is_array($definitionValue)) { + continue; + } + + $definition = $definitionValue['definition'] ?? null; + $definitionId = is_array($definition) ? ($definition['id'] ?? null) : null; + $definitionValueId = $definitionValue['id'] ?? null; + + if (! is_string($definitionValueId) || $definitionValueId === '') { + continue; + } + + if (! is_string($definitionId) || $definitionId === '') { + continue; + } + + $presentationValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'presentationValues', [ + '{id}' => $policyId, + '{definitionValueId}' => $definitionValueId, + ]); + + $setting = [ + 'enabled' => (bool) ($definitionValue['enabled'] ?? false), + 'definition@odata.bind' => "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')", + '#Definition_Id' => $definitionId, + '#Definition_displayName' => is_array($definition) ? ($definition['displayName'] ?? null) : null, + '#Definition_classType' => is_array($definition) ? ($definition['classType'] ?? null) : null, + '#Definition_categoryPath' => is_array($definition) ? ($definition['categoryPath'] ?? null) : null, + ]; + + $setting = array_filter($setting, static fn ($value) => $value !== null); + + if (! $presentationValuesPath) { + $settings[] = $setting; + + continue; + } + + $presentationValues = []; + $presentationNext = $presentationValuesPath; + + while ($presentationNext) { + $pvResponse = $this->graphClient->request('GET', $presentationNext, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]); + + if ($pvResponse->failed()) { + $metadata['warnings'] = array_values(array_unique(array_merge( + $metadata['warnings'] ?? [], + ['Hydration warning: could not load some Administrative Templates presentation values.'] + ))); + break; + } + + $presentationValues = array_merge($presentationValues, $pvResponse->data['value'] ?? []); + $presentationNextLink = $pvResponse->data['@odata.nextLink'] ?? null; + + if (! $presentationNextLink) { + break; + } + + $presentationNext = $this->stripGraphBaseUrl((string) $presentationNextLink); + } + + if ($presentationValues !== []) { + $setting['presentationValues'] = []; + + foreach ($presentationValues as $presentationValue) { + if (! is_array($presentationValue)) { + continue; + } + + $presentation = $presentationValue['presentation'] ?? null; + $presentationId = is_array($presentation) ? ($presentation['id'] ?? null) : null; + + if (! is_string($presentationId) || $presentationId === '') { + continue; + } + + $cleanPresentationValue = Arr::except($presentationValue, [ + 'presentation', + 'id', + 'lastModifiedDateTime', + 'createdDateTime', + ]); + + $cleanPresentationValue['presentation@odata.bind'] = "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')/presentations('{$presentationId}')"; + + $label = is_array($presentation) ? ($presentation['label'] ?? null) : null; + + if (is_string($label) && $label !== '') { + $cleanPresentationValue['#Presentation_Label'] = $label; + } + + $cleanPresentationValue['#Presentation_Id'] = $presentationId; + + $setting['presentationValues'][] = $cleanPresentationValue; + } + + if ($setting['presentationValues'] === []) { + unset($setting['presentationValues']); + } + } + + $settings[] = $setting; + } + + $payload['definitionValues'] = $settings; + + return [$payload, $metadata]; + } + /** * Hydrate compliance policies with scheduled actions (notification templates). * diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index eb86cb9..d5082b1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -5,6 +5,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\RestoreRun; use App\Models\Tenant; use App\Services\AssignmentRestoreService; @@ -97,6 +98,88 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt * * @param array|null $selectedItemIds */ + public function executeFromPolicyVersion( + Tenant $tenant, + PolicyVersion $version, + bool $dryRun = true, + ?string $actorEmail = null, + ?string $actorName = null, + array $groupMapping = [], + ): RestoreRun { + if ($version->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Policy version does not belong to the provided tenant.'); + } + + $policy = $version->policy; + + if (! $policy) { + throw new \RuntimeException('Policy version has no associated policy.'); + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $version->version_number + ), + 'created_by' => $actorEmail, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $version->id, + 'policy_version_number' => $version->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($version->scope_tags) ? $version->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ + 'source' => 'policy_version', + 'display_name' => $policy->display_name, + 'policy_version_id' => $version->id, + 'policy_version_number' => $version->version_number, + 'version_captured_at' => $version->captured_at?->toIso8601String(), + ]; + + if (is_array($scopeTagIds) && $scopeTagIds !== []) { + $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; + } + + if (is_array($scopeTagNames) && $scopeTagNames !== []) { + $backupItemMetadata['scope_tag_names'] = $scopeTagNames; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_version_id' => $version->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $version->captured_at ?? CarbonImmutable::now(), + 'payload' => $version->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $version->assignments, + ]); + + return $this->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: $dryRun, + actorEmail: $actorEmail, + actorName: $actorName, + groupMapping: $groupMapping, + ); + } + public function execute( Tenant $tenant, BackupSet $backupSet, @@ -151,6 +234,7 @@ public function execute( $foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0); $foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries); $scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? []; + $scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries); if (! $dryRun) { $this->auditFoundationMapping( @@ -473,6 +557,31 @@ public function execute( $assignmentOutcomes = null; $assignmentSummary = null; $restoredAssignments = null; + $definitionValueApply = null; + + if ( + ! $dryRun + && $item->policy_type === 'groupPolicyConfiguration' + && is_array($originalPayload) + && is_array($originalPayload['definitionValues'] ?? null) + && $originalPayload['definitionValues'] !== [] + ) { + $definitionValueApply = $this->applyGroupPolicyDefinitionValues( + tenant: $tenant, + tenantIdentifier: $tenantIdentifier, + policyId: $createdPolicyId ?? $item->policy_identifier, + definitionValues: $originalPayload['definitionValues'], + graphOptions: $graphOptions, + context: $context, + ); + + $definitionSummary = $definitionValueApply['summary'] ?? null; + + if (is_array($definitionSummary) && ($definitionSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') { + $itemStatus = 'partial'; + $resultReason = 'Administrative Template settings restored with failures'; + } + } if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; @@ -561,6 +670,11 @@ public function execute( $result['assignment_summary'] = $assignmentSummary; } + if (is_array($definitionValueApply)) { + $result['definition_value_outcomes'] = $definitionValueApply['outcomes'] ?? []; + $result['definition_value_summary'] = $definitionValueApply['summary'] ?? null; + } + if ($complianceActionSummary !== null) { $result['compliance_action_summary'] = $complianceActionSummary; } @@ -580,6 +694,13 @@ public function execute( ->first(); if ($policy && $itemStatus === 'applied') { + $scopeTagsForVersion = $this->buildScopeTagsForVersion( + scopeTagIds: $mappedScopeTagIds ?? null, + backupItemMetadata: $item->metadata ?? [], + scopeTagMapping: $scopeTagMapping, + scopeTagNamesById: $scopeTagNamesById, + ); + $this->versionService->captureVersion( policy: $policy, payload: $item->payload, @@ -589,7 +710,8 @@ public function execute( 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, ], - assignments: $restoredAssignments, + assignments: $item->assignments, + scopeTags: $scopeTagsForVersion, ); } } @@ -1960,6 +2082,330 @@ private function stripOdataAndReadOnly(array $payload): array return $clean; } + /** + * Administrative Templates (groupPolicyConfiguration) restore: wipe existing definitionValues and recreate from snapshot. + * + * @param array $definitionValues + * @param array $graphOptions + * @param array $context + * @return array{outcomes: array>, summary: array{success:int,failed:int,skipped:int}} + */ + private function applyGroupPolicyDefinitionValues( + Tenant $tenant, + string $tenantIdentifier, + string $policyId, + array $definitionValues, + array $graphOptions, + array $context, + ): array { + $outcomes = []; + $summary = ['success' => 0, 'failed' => 0, 'skipped' => 0]; + + $listPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues"; + $createPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues"; + + $this->graphLogger->logRequest('restore_group_policy_definition_values_list', $context + [ + 'method' => 'GET', + 'endpoint' => $listPath, + ]); + + $existingResponse = $this->graphClient->request('GET', $listPath, $graphOptions); + + $this->graphLogger->logResponse('restore_group_policy_definition_values_list', $existingResponse, $context + [ + 'method' => 'GET', + 'endpoint' => $listPath, + ]); + + $existing = $existingResponse->data['value'] ?? []; + + foreach ($existing as $existingValue) { + $existingId = is_array($existingValue) ? ($existingValue['id'] ?? null) : null; + + if (! is_string($existingId) || $existingId === '') { + continue; + } + + $deletePath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues/{$existingId}"; + + $this->graphLogger->logRequest('restore_group_policy_definition_values_delete', $context + [ + 'method' => 'DELETE', + 'endpoint' => $deletePath, + 'definition_value_id' => $existingId, + ]); + + $deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions); + + $this->graphLogger->logResponse('restore_group_policy_definition_values_delete', $deleteResponse, $context + [ + 'method' => 'DELETE', + 'endpoint' => $deletePath, + 'definition_value_id' => $existingId, + ]); + } + + foreach ($definitionValues as $definitionValue) { + if (! is_array($definitionValue)) { + continue; + } + + $displayName = $definitionValue['#Definition_displayName'] ?? null; + $definitionId = $definitionValue['#Definition_Id'] ?? null; + + $sanitized = $this->sanitizeGroupPolicyDefinitionValue($definitionValue); + + if (! isset($sanitized['definition@odata.bind'])) { + $outcomes[] = [ + 'status' => 'skipped', + 'definition_id' => $definitionId, + 'definition' => $displayName, + 'reason' => 'Missing definition@odata.bind', + ]; + $summary['skipped']++; + + continue; + } + + $this->graphLogger->logRequest('restore_group_policy_definition_values_create', $context + [ + 'method' => 'POST', + 'endpoint' => $createPath, + 'definition_id' => $definitionId, + 'definition' => $displayName, + ]); + + $createResponse = $this->graphClient->request('POST', $createPath, [ + 'json' => $sanitized, + ] + $graphOptions); + + $this->graphLogger->logResponse('restore_group_policy_definition_values_create', $createResponse, $context + [ + 'method' => 'POST', + 'endpoint' => $createPath, + 'definition_id' => $definitionId, + 'definition' => $displayName, + ]); + + if ($createResponse->successful()) { + $outcomes[] = [ + 'status' => 'success', + 'definition_id' => $definitionId, + 'definition' => $displayName, + ]; + $summary['success']++; + } else { + $outcomes[] = array_filter([ + 'status' => 'failed', + 'definition_id' => $definitionId, + 'definition' => $displayName, + 'reason' => $createResponse->meta['error_message'] ?? 'Graph create failed', + 'graph_error_message' => $createResponse->meta['error_message'] ?? null, + 'graph_error_code' => $createResponse->meta['error_code'] ?? null, + 'graph_request_id' => $createResponse->meta['request_id'] ?? null, + 'graph_client_request_id' => $createResponse->meta['client_request_id'] ?? null, + ], static fn ($value) => $value !== null); + $summary['failed']++; + } + + usleep(100000); + } + + $this->auditLogger->log( + tenant: $tenant, + action: 'restore.group_policy_definition_values.applied', + context: [ + 'metadata' => [ + 'tenant' => $tenantIdentifier, + 'policy_id' => $policyId, + 'summary' => $summary, + ], + ], + status: ($summary['failed'] ?? 0) > 0 ? 'warning' : 'success', + resourceType: 'policy', + resourceId: $policyId + ); + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + /** + * @param array $definitionValue + * @return array + */ + private function sanitizeGroupPolicyDefinitionValue(array $definitionValue): array + { + $clean = []; + + foreach ($definitionValue as $key => $value) { + if (is_string($key) && str_starts_with($key, '#')) { + continue; + } + + if ($key === 'id') { + continue; + } + + if ($key === 'presentationValues' && is_array($value)) { + $cleanPresentationValues = []; + + foreach ($value as $presentationValue) { + if (! is_array($presentationValue)) { + continue; + } + + $presentationClean = []; + + foreach ($presentationValue as $pKey => $pValue) { + if (is_string($pKey) && str_starts_with($pKey, '#')) { + continue; + } + + if (in_array($pKey, ['id', 'createdDateTime', 'lastModifiedDateTime', 'presentation'], true)) { + continue; + } + + $presentationClean[$pKey] = $pValue; + } + + if ($presentationClean !== []) { + $cleanPresentationValues[] = $presentationClean; + } + } + + if ($cleanPresentationValues !== []) { + $clean['presentationValues'] = $cleanPresentationValues; + } + + continue; + } + + $clean[$key] = $value; + } + + return $clean; + } + + /** + * @param array> $foundationEntries + * @return array + */ + private function buildScopeTagNameLookup(array $foundationEntries): array + { + $names = []; + + foreach ($foundationEntries as $entry) { + if (! is_array($entry)) { + continue; + } + + if (($entry['type'] ?? null) !== 'roleScopeTag') { + continue; + } + + $targetId = $entry['targetId'] ?? null; + $targetName = $entry['targetName'] ?? null; + + if (! is_string($targetId) || $targetId === '') { + continue; + } + + if (! is_string($targetName) || $targetName === '') { + continue; + } + + $names[$targetId] = $targetName; + } + + return $names; + } + + /** + * @param array|null $scopeTagIds + * @param array $backupItemMetadata + * @param array $scopeTagMapping + * @param array $scopeTagNamesById + * @return array{ids: array, names: array}|null + */ + private function buildScopeTagsForVersion( + ?array $scopeTagIds, + array $backupItemMetadata, + array $scopeTagMapping, + array $scopeTagNamesById, + ): ?array { + if ($scopeTagIds === null) { + return null; + } + + $ids = []; + + foreach ($scopeTagIds as $id) { + if (! is_string($id) && ! is_int($id)) { + continue; + } + + $id = (string) $id; + + if ($id === '') { + continue; + } + + $ids[] = $id; + } + + $ids = array_values(array_unique($ids)); + + if ($ids === []) { + return null; + } + + $namesById = $scopeTagNamesById; + + $metaScopeTagIds = $backupItemMetadata['scope_tag_ids'] ?? null; + $metaScopeTagNames = $backupItemMetadata['scope_tag_names'] ?? null; + + if (is_array($metaScopeTagIds) && is_array($metaScopeTagNames)) { + foreach ($metaScopeTagIds as $index => $sourceId) { + if (! is_string($sourceId) && ! is_int($sourceId)) { + continue; + } + + $sourceId = (string) $sourceId; + + if ($sourceId === '') { + continue; + } + + $name = $metaScopeTagNames[$index] ?? null; + + if (! is_string($name) || $name === '') { + continue; + } + + $targetId = $scopeTagMapping[$sourceId] ?? $sourceId; + + if ($targetId !== '' && ! array_key_exists($targetId, $namesById)) { + $namesById[$targetId] = $name; + } + } + } + + $names = []; + + foreach ($ids as $id) { + if ($id === '0') { + $names[] = 'Default'; + + continue; + } + + $names[] = $namesById[$id] ?? "Unknown (ID: {$id})"; + } + + return [ + 'ids' => $ids, + 'names' => $names, + ]; + } + private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void { if (! $tenant->isActive()) { diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php index 6fc64c1..1b74907 100644 --- a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -26,6 +26,121 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + $map = []; + + if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { + $title = $normalized['settings_table']['title'] ?? 'Settings'; + $prefix = is_string($title) && $title !== '' ? $title.' > ' : ''; + $rows = $normalized['settings_table']['rows']; + + $baseLabels = array_values(array_filter(array_map(function (mixed $row): ?string { + if (! is_array($row)) { + return null; + } + + return $this->buildSettingsCatalogDiffLabel($row, includePath: false); + }, $rows))); + + $labelCounts = array_count_values($baseLabels); + + foreach ($rows as $row) { + if (! is_array($row)) { + continue; + } + + $baseLabel = $this->buildSettingsCatalogDiffLabel($row, includePath: false); + $label = $baseLabel; + + if (($labelCounts[$baseLabel] ?? 0) > 1) { + $path = $row['path'] ?? null; + $pathLabel = is_string($path) && $path !== '' ? $path : null; + + $label = $this->buildSettingsCatalogDiffLabel($row, includePath: true); + + if ($pathLabel !== null) { + $label .= ' @ '.$pathLabel; + } + } + + $key = $prefix.$label; + $map[$key] = $row['value'] ?? null; + } + } + + foreach ($normalized['settings'] ?? [] as $block) { + if (! is_array($block)) { + continue; + } + + $title = $block['title'] ?? null; + $prefix = is_string($title) && $title !== '' ? $title.' > ' : ''; + + if (($block['type'] ?? null) === 'table') { + foreach ($block['rows'] ?? [] as $row) { + if (! is_array($row)) { + continue; + } + + $key = $prefix.($row['path'] ?? $row['label'] ?? 'entry'); + $map[$key] = $row['value'] ?? null; + } + + continue; + } + + foreach ($block['entries'] ?? [] as $entry) { + if (! is_array($entry)) { + continue; + } + + $key = $prefix.($entry['key'] ?? 'entry'); + $map[$key] = $entry['value'] ?? null; + } + } + + return $map; + } + + /** + * @param array $row + */ + private function buildSettingsCatalogDiffLabel(array $row, bool $includePath): string + { + $category = $row['category'] ?? null; + $definition = $row['definition'] ?? null; + $definitionId = $row['definition_id'] ?? null; + + $label = is_string($definition) && $definition !== '' ? $definition : 'Setting'; + + if ($includePath) { + $path = $row['path'] ?? null; + + if (is_string($path) && $path !== '') { + $label = $path; + } + + if ( + is_string($label) + && is_string($definitionId) + && $definitionId !== '' + && is_string($definition) + && $definition !== '' + ) { + $parts = explode(' > ', $label); + + if ($parts !== [] && end($parts) === $definitionId) { + $parts[count($parts) - 1] = $definition; + $label = implode(' > ', $parts); + } + } + } + + if (is_string($category) && $category !== '' && $category !== '-') { + $label = $category.' > '.$label; + } + + return $label; } } diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 5ecefe6..6c6ac67 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -48,6 +48,26 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'definitionValues', + ], + 'member_hydration_strategy' => 'subresource_definition_values', + 'subresources' => [ + 'definitionValues' => [ + 'path' => 'deviceManagement/groupPolicyConfigurations/{id}/definitionValues?$expand=definition', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + 'presentationValues' => [ + 'path' => 'deviceManagement/groupPolicyConfigurations/{id}/definitionValues/{definitionValueId}/presentationValues?$expand=presentation', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], 'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments', 'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign', 'assignments_create_method' => 'POST', diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 2f7e0b7..5a593c4 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -1,4 +1,19 @@
+ @php + $scopeTags = $version->scope_tags['names'] ?? []; + @endphp + @if(!empty($scopeTags)) + +
+ @foreach($scopeTags as $tag) + + {{ $tag }} + + @endforeach +
+
+ @endif + @if($version->assignments && count($version->assignments) > 0)
- @php - $scopeTags = $version->scope_tags['names'] ?? []; - @endphp - @if(!empty($scopeTags)) -
-

Scope Tags

-
- @foreach($scopeTags as $tag) - - {{ $tag }} - - @endforeach -
-
- @endif -

Assignment Details

diff --git a/specs/010-admin-templates/plan.md b/specs/010-admin-templates/plan.md new file mode 100644 index 0000000..075c09e --- /dev/null +++ b/specs/010-admin-templates/plan.md @@ -0,0 +1,22 @@ +# Implementation Plan: Administrative Templates (010) + +**Branch**: `feat/010-admin-templates` +**Date**: 2025-12-29 +**Spec Source**: [spec.md](./spec.md) + +## Summary +Make `groupPolicyConfiguration` snapshots/restores accurate by hydrating and applying `definitionValues` and their `presentationValues`, and present a readable normalized view in Filament. + +## Execution Steps +1. Graph contract updates + - Add subresource/hydration metadata for `definitionValues` + `presentationValues`. +2. Snapshot capture hydration + - Extend `PolicySnapshotService` to hydrate Admin Template settings into the payload. +3. Restore + - Extend `RestoreService` to “wipe and replace” definitionValues/presentationValues from snapshot. +4. UI normalization + - Add a normalizer that renders configured settings as readable rows. +5. Tests + formatting + - Add targeted Pest tests for snapshot hydration, normalized display, and restore. + - Run `./vendor/bin/pint --dirty` and the affected tests. + diff --git a/specs/010-admin-templates/spec.md b/specs/010-admin-templates/spec.md new file mode 100644 index 0000000..e9e11af --- /dev/null +++ b/specs/010-admin-templates/spec.md @@ -0,0 +1,52 @@ +# Feature Specification: Administrative Templates (Group Policy Configurations) (010) + +**Feature Branch**: `feat/010-admin-templates` +**Created**: 2025-12-29 +**Status**: Draft +**Input**: `.specify/spec.md` (groupPolicyConfiguration scope), `references/IntuneManagement-master` (definitionValues/presentationValues pattern) + +## Overview +Add reliable coverage for **Administrative Templates** (`groupPolicyConfiguration`) in the existing inventory/backup/version/restore flows. + +Administrative Templates are not fully represented by the base entity alone; the effective policy settings live in: +- `definitionValues` (with expanded `definition`) +- `presentationValues` per definitionValue (with expanded `presentation`) + +## In Scope +- Policy type: `groupPolicyConfiguration` (`deviceManagement/groupPolicyConfigurations`) +- Snapshot capture hydrates: + - `definitionValues?$expand=definition` + - `presentationValues?$expand=presentation` for each definitionValue +- Restore supports “snapshot as source of truth” for Admin Templates settings: + - delete existing definitionValues + - recreate definitionValues + presentationValues from snapshot +- UI shows a readable “Normalized settings” view for Admin Templates (definitions + values). + +## Out of Scope (v1) +- Translating every ADMX value into Intune-portal-identical wording for every template +- Advanced partial-restore / per-setting selection + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Inventory + readable view (P1) +As an admin, I can open an Administrative Template policy and see its effective configured settings (not only metadata). + +**Acceptance** +1. Policy detail shows a structured list/table of configured settings (definition + value). +2. Policy Versions store the hydrated settings and render them in “Normalized settings”. + +### User Story 2 — Backup/Version capture includes definition values (P1) +As an admin, a backup/version of an Administrative Template includes the `definitionValues` + `presentationValues`. + +**Acceptance** +1. Backup payload contains `definitionValues` array. +2. Each definitionValue includes expanded `definition` and a `presentationValues` collection (when present). + +### User Story 3 — Restore settings (P1) +As an admin, restoring an Administrative Template brings the target tenant’s definition values back to the snapshot state. + +**Acceptance** +1. Restore deletes existing definitionValues before recreate. +2. Restore recreates definitionValues and their presentationValues. +3. Clear per-item audit outcomes on failures. + diff --git a/specs/010-admin-templates/tasks.md b/specs/010-admin-templates/tasks.md new file mode 100644 index 0000000..3e4080d --- /dev/null +++ b/specs/010-admin-templates/tasks.md @@ -0,0 +1,29 @@ +# Tasks: Administrative Templates (Group Policy Configurations) (010) + +**Branch**: `feat/010-admin-templates` | **Date**: 2025-12-29 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts + Snapshot Hydration +- [x] T001 Extend `config/graph_contracts.php` for `groupPolicyConfiguration` (hydration/subresources metadata). +- [x] T002 Hydrate `definitionValues` (+ `presentationValues`) in `app/Services/Intune/PolicySnapshotService.php`. + +## Phase 2: Restore (Definition Values) +- [x] T003 Implement restore apply for `definitionValues` and `presentationValues` in `app/Services/Intune/RestoreService.php`. + +## Phase 3: UI Normalization +- [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable). +- [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`. +- [x] T010 Ensure restore-created versions keep assignments + show scope tags independently. +- [x] T012 Add “Restore to Intune” from a specific PolicyVersion (rollback) + show policy version numbers in restore selection UI. + +## Phase 4: Tests + Verification +- [x] T005 Add tests for hydration + UI display. +- [x] T006 Add tests for restore definitionValues apply. +- [x] T007 Run tests (targeted). +- [x] T008 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T011 Fix Admin Templates hydration paging (`@odata.nextLink`) and add coverage. + +## Open TODOs (Follow-up) +- [ ] Improve Admin Templates value formatting to better match Intune UI (presentation values / multi-value rendering). +- [ ] Add a small “snapshot completeness” indicator (e.g., definitionValues count) to reduce confusion when older/incomplete snapshots exist. +- [ ] Implement remaining Windows “Templates” profile types (separate policy types/odata subtypes; new specs per type). diff --git a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php new file mode 100644 index 0000000..e7b424e --- /dev/null +++ b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php @@ -0,0 +1,144 @@ +requests[] = ['getPolicy', $policyType, $policyId]; + + return new GraphResponse(true, ['payload' => [ + 'id' => $policyId, + 'displayName' => 'Admin Templates Alpha', + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + ]]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [strtoupper($method), $path]; + + if (str_contains($path, '/definitionValues') && str_contains($path, '$expand=definition')) { + return new GraphResponse(true, [ + 'value' => [ + [ + 'id' => 'dv-1', + 'enabled' => true, + 'definition' => [ + 'id' => 'def-1', + 'displayName' => 'Block legacy auth', + 'classType' => 'user', + 'categoryPath' => 'Windows Components\\Security Options', + ], + ], + ], + ]); + } + + if (str_contains($path, '/presentationValues') && str_contains($path, '$expand=presentation')) { + return new GraphResponse(true, [ + 'value' => [ + [ + 'id' => 'pv-1', + 'value' => 'enabled', + 'presentation' => [ + 'id' => 'pres-1', + 'label' => 'State', + ], + ], + ], + ]); + } + + return new GraphResponse(true, []); + } +} + +test('group policy configuration snapshot hydrates definition values and renders in policy detail', function () { + $client = new GroupPolicyHydrationGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-gpo-hydration', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; + $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'gpo-hydrate', + 'policy_type' => 'groupPolicyConfiguration', + 'display_name' => 'Admin Templates Alpha', + 'platform' => 'windows', + ]); + + /** @var BackupService $backupService */ + $backupService = app(BackupService::class); + $backupSet = $backupService->createBackupSet($tenant, [$policy->id], actorEmail: 'tester@example.com'); + + $item = $backupSet->items()->first(); + expect($item->payload)->toHaveKey('definitionValues'); + expect($item->payload['definitionValues'])->toBeArray(); + expect($item->payload['definitionValues'][0])->toHaveKey('definition@odata.bind'); + expect($item->payload['definitionValues'][0])->toHaveKey('presentationValues'); + expect($item->payload['definitionValues'][0]['presentationValues'][0])->toHaveKey('presentation@odata.bind'); + expect($item->payload['definitionValues'][0]['#Definition_displayName'])->toBe('Block legacy auth'); + expect($item->payload['definitionValues'][0]['presentationValues'][0]['#Presentation_Label'])->toBe('State'); + + /** @var VersionService $versions */ + $versions = app(VersionService::class); + $versions->captureVersion( + policy: $policy, + payload: $item->payload, + createdBy: 'tester@example.com', + metadata: ['source' => 'test', 'backup_set_id' => $backupSet->id], + ); + + $user = User::factory()->create(); + + $response = $this + ->actingAs($user) + ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Block legacy auth'); + $response->assertSee('State'); +}); diff --git a/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php b/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php new file mode 100644 index 0000000..24e1500 --- /dev/null +++ b/tests/Feature/Filament/GroupPolicyConfigurationNormalizedDiffTest.php @@ -0,0 +1,29 @@ +flattenForDiff( + snapshot: [ + 'id' => 'gpo-1', + 'displayName' => 'Admin Templates Alpha', + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + 'definitionValues' => [ + [ + 'enabled' => true, + 'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')', + '#Definition_Id' => 'def-1', + '#Definition_displayName' => 'Block legacy auth', + '#Definition_categoryPath' => 'Windows Components\\Security Options', + ], + ], + ], + policyType: 'groupPolicyConfiguration', + platform: 'windows', + ); + + $keys = array_keys($flat); + + expect($keys)->toContain('Administrative Template settings > Windows Components\\Security Options > Block legacy auth (def-1)'); + expect(implode("\n", $keys))->not->toContain('graph.microsoft.com'); +}); diff --git a/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php new file mode 100644 index 0000000..6d12db2 --- /dev/null +++ b/tests/Feature/Filament/GroupPolicyConfigurationRestoreTest.php @@ -0,0 +1,183 @@ + + */ + public array $applyPolicyCalls = []; + + /** + * @var array + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyPolicyCalls[] = [ + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'payload' => $payload, + ]; + + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + $response = array_shift($this->requestResponses); + + return $response ?? new GraphResponse(true, []); + } +} + +test('restore applies administrative template definition values', function () { + $policyResponse = new GraphResponse(true, [], 200, [], [], ['request_id' => 'req-policy', 'client_request_id' => 'client-policy']); + + $listExisting = new GraphResponse(true, [ + 'value' => [ + ['id' => 'existing-dv-1'], + ], + ]); + + $deleteExisting = new GraphResponse(true, []); + $createDefinitionValue = new GraphResponse(true, []); + + $client = new GroupPolicyRestoreGraphClient($policyResponse, [ + $listExisting, + $deleteExisting, + $createDefinitionValue, + ]); + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-gpo-restore', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'gpo-1', + 'policy_type' => 'groupPolicyConfiguration', + 'display_name' => 'Admin Templates Alpha', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $payload = [ + 'id' => 'gpo-1', + 'displayName' => 'Admin Templates Alpha', + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + 'definitionValues' => [ + [ + 'enabled' => true, + 'definition@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')', + '#Definition_Id' => 'def-1', + '#Definition_displayName' => 'Block legacy auth', + 'presentationValues' => [ + [ + 'presentation@odata.bind' => 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions(\'def-1\')/presentations(\'pres-1\')', + '#Presentation_Label' => 'State', + '#Presentation_Id' => 'pres-1', + 'value' => 'enabled', + ], + ], + ], + ], + ]; + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => $payload, + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + )->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['definition_value_summary']['success'])->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policy_type'])->toBe('groupPolicyConfiguration'); + + expect($client->requestCalls)->toHaveCount(3); + expect($client->requestCalls[0]['method'])->toBe('GET'); + expect($client->requestCalls[0]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues'); + expect($client->requestCalls[1]['method'])->toBe('DELETE'); + expect($client->requestCalls[1]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues/existing-dv-1'); + expect($client->requestCalls[2]['method'])->toBe('POST'); + expect($client->requestCalls[2]['path'])->toContain('/deviceManagement/groupPolicyConfigurations/gpo-1/definitionValues'); + expect($client->requestCalls[2]['payload'])->toBeArray(); + expect($client->requestCalls[2]['payload'])->toHaveKey('definition@odata.bind'); + expect($client->requestCalls[2]['payload'])->not->toHaveKey('#Definition_displayName'); +}); diff --git a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php new file mode 100644 index 0000000..d32d790 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php @@ -0,0 +1,153 @@ + + */ + public array $requestCalls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $method = strtoupper($method); + + $this->requestCalls[] = [ + 'method' => $method, + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + if ($method === 'GET') { + return new GraphResponse(true, ['value' => []]); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore can execute from a specific policy version snapshot', function () { + $client = new PolicyVersionRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-version-restore', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'gpo-versioned-1', + 'policy_type' => 'groupPolicyConfiguration', + 'display_name' => 'Admin Templates', + 'platform' => 'windows', + ]); + + $snapshotWithThree = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + 'definitionValues' => collect(range(1, 3)) + ->map(fn (int $i) => [ + 'enabled' => true, + 'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')", + '#Definition_Id' => "def-{$i}", + '#Definition_displayName' => "Setting {$i}", + ])->all(), + ]; + + $snapshotWithFive = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + 'definitionValues' => collect(range(1, 5)) + ->map(fn (int $i) => [ + 'enabled' => true, + 'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')", + '#Definition_Id' => "def-{$i}", + '#Definition_displayName' => "Setting {$i}", + ])->all(), + ]; + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => now(), + 'snapshot' => $snapshotWithThree, + 'metadata' => ['source' => 'version_capture'], + ]); + + $versionToRestore = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => now(), + 'snapshot' => $snapshotWithFive, + 'metadata' => ['source' => 'version_capture'], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->executeFromPolicyVersion( + tenant: $tenant, + version: $versionToRestore, + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + )->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['definition_value_summary']['success'])->toBe(5); + + $definitionValueCreateCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues')) + ->values(); + + expect($definitionValueCreateCalls)->toHaveCount(5); +}); diff --git a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php new file mode 100644 index 0000000..6a9d3e3 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php @@ -0,0 +1,59 @@ + env('INTUNE_TENANT_ID', 'local-tenant'), + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + 'displayName' => 'Policy A', + ], + 'assignments' => null, + 'scope_tags' => [ + 'ids' => ['0', 'scope-1'], + 'names' => ['Default', 'Verbund-1'], + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + + $response->assertOk(); + $response->assertSee('Scope Tags'); + $response->assertSee('Default'); + $response->assertSee('Verbund-1'); +}); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 590049c..2fd856c 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -49,6 +49,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon } }); + config()->set('graph_contracts.types.deviceConfiguration.assignments_payload_key', 'assignments'); + $tenant = Tenant::create([ 'tenant_id' => 'tenant-1', 'name' => 'Tenant One', @@ -77,7 +79,24 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, - 'payload' => ['foo' => 'bar'], + 'payload' => [ + 'foo' => 'bar', + 'roleScopeTagIds' => ['0', 'scope-1'], + ], + 'metadata' => [ + 'scope_tag_ids' => ['0', 'scope-1'], + 'scope_tag_names' => ['Default', 'Verbund-1'], + ], + 'assignments' => [ + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + 'group_display_name' => 'Group One', + ], + 'intent' => 'apply', + ], + ], ]); $user = User::factory()->create(['email' => 'tester@example.com']); @@ -102,6 +121,14 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ]); expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + $version = PolicyVersion::where('policy_id', $policy->id)->first(); + expect($version)->not->toBeNull(); + expect($version->scope_tags)->toBe([ + 'ids' => ['0', 'scope-1'], + 'names' => ['Default', 'Verbund-1'], + ]); + expect($version->assignments)->toBe($backupItem->assignments); }); test('restore execution records foundation mappings', function () { diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php new file mode 100644 index 0000000..3c34b38 --- /dev/null +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDiffTest.php @@ -0,0 +1,55 @@ + 'cat-1', + 'display_name' => 'Account Management', + 'description' => null, + ]); + + SettingsCatalogDefinition::create([ + 'definition_id' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy', + 'display_name' => 'Deletion Policy', + 'description' => null, + 'help_text' => null, + 'category_id' => 'cat-1', + 'ux_behavior' => null, + 'raw' => [], + ]); + + $flat = app(PolicyNormalizer::class)->flattenForDiff( + snapshot: [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'id' => 'scp-policy-1', + 'name' => 'Settings Catalog Policy', + 'platforms' => 'windows10', + 'technologies' => 'mdm', + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy', + 'choiceSettingValue' => [ + 'value' => 'enabled', + ], + ], + ], + ], + ], + policyType: 'settingsCatalogPolicy', + platform: 'windows', + ); + + $keys = array_keys($flat); + + expect($keys)->toContain('Settings > Account Management > Deletion Policy'); + expect(implode("\n", $keys))->not->toContain('device_vendor_msft_accountmanagement_userprofilemanagement_deletionpolicy'); +});