From e46db605af086afd2da489c9c335fb5b11ec9bee Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 29 Dec 2025 17:23:41 +0100 Subject: [PATCH] feat: admin templates hydration + restore --- app/Providers/AppServiceProvider.php | 2 + app/Services/Graph/GraphContractRegistry.php | 21 ++ .../GroupPolicyConfigurationNormalizer.php | 162 ++++++++++++ app/Services/Intune/PolicySnapshotService.php | 168 +++++++++++++ app/Services/Intune/RestoreService.php | 232 ++++++++++++++++++ config/graph_contracts.php | 20 ++ specs/010-admin-templates/plan.md | 22 ++ specs/010-admin-templates/spec.md | 52 ++++ specs/010-admin-templates/tasks.md | 21 ++ .../GroupPolicyConfigurationHydrationTest.php | 144 +++++++++++ .../GroupPolicyConfigurationRestoreTest.php | 183 ++++++++++++++ 11 files changed, 1027 insertions(+) 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/GroupPolicyConfigurationRestoreTest.php 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..c32892c --- /dev/null +++ b/app/Services/Intune/GroupPolicyConfigurationNormalizer.php @@ -0,0 +1,162 @@ +>, 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 = $definitionValue['definition@odata.bind'] ?? (is_string($definitionId) ? $definitionId : "definitionValues[{$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' => is_string($path) ? Str::limit($path, 200) : "definitionValues[{$index}]", + '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 !== ''))); + } +} diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 55ccd51..aeec01f 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,164 @@ 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'] ?? []); + $nextPath = $response->data['@odata.nextLink'] ?? null; + } + + 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'] ?? []); + $presentationNext = $pvResponse->data['@odata.nextLink'] ?? null; + } + + 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..efa9ee7 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -473,6 +473,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 +586,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; } @@ -1960,6 +1990,208 @@ 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; + } + private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void { if (! $tenant->isActive()) { 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/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..b995e01 --- /dev/null +++ b/specs/010-admin-templates/tasks.md @@ -0,0 +1,21 @@ +# 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 +- [ ] T001 Extend `config/graph_contracts.php` for `groupPolicyConfiguration` (hydration/subresources metadata). +- [ ] T002 Hydrate `definitionValues` (+ `presentationValues`) in `app/Services/Intune/PolicySnapshotService.php`. + +## Phase 2: Restore (Definition Values) +- [ ] T003 Implement restore apply for `definitionValues` and `presentationValues` in `app/Services/Intune/RestoreService.php`. + +## Phase 3: UI Normalization +- [ ] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable). + +## Phase 4: Tests + Verification +- [ ] T005 Add tests for hydration + UI display. +- [ ] T006 Add tests for restore definitionValues apply. +- [ ] T007 Run tests (targeted). +- [ ] T008 Run Pint (`./vendor/bin/pint --dirty`). + 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/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'); +});