diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 3f47bab..094fe16 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -781,8 +781,17 @@ private function endpointFor(string $policyType): string return $contractResource; } - $supported = config('tenantpilot.supported_policy_types', []); - foreach ($supported as $type) { + $builtinEndpoint = $this->builtinEndpointFor($policyType); + if ($builtinEndpoint !== null) { + return $builtinEndpoint; + } + + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []), + ); + + foreach ($types as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { return $type['endpoint']; } @@ -791,6 +800,16 @@ private function endpointFor(string $policyType): string return 'deviceManagement/'.$policyType; } + private function builtinEndpointFor(string $policyType): ?string + { + return match ($policyType) { + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy' => 'deviceManagement/configurationPolicies', + default => null, + }; + } + private function getAccessToken(array $context): string { $tenant = $context['tenant'] ?? $this->tenantId; diff --git a/app/Services/Intune/ConfigurationPolicyTemplateResolver.php b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php new file mode 100644 index 0000000..3432c59 --- /dev/null +++ b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php @@ -0,0 +1,388 @@ +> + */ + private array $templateCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $familyCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $templateDefinitionCache = []; + + public function __construct( + private readonly GraphClientInterface $graphClient, + ) {} + + /** + * @param array $templateReference + * @param array $graphOptions + * @return array{success:bool,template_id:?string,template_reference:?array,reason:?string,warnings:array} + */ + public function resolveTemplateReference(Tenant $tenant, array $templateReference, array $graphOptions = []): array + { + $warnings = []; + + $templateId = $this->extractString($templateReference, ['templateId', 'TemplateId']); + $templateFamily = $this->extractString($templateReference, ['templateFamily', 'TemplateFamily']); + $templateDisplayName = $this->extractString($templateReference, ['templateDisplayName', 'TemplateDisplayName']); + $templateDisplayVersion = $this->extractString($templateReference, ['templateDisplayVersion', 'TemplateDisplayVersion']); + + if ($templateId !== null) { + $templateOutcome = $this->getTemplate($tenant, $templateId, $graphOptions); + + if ($templateOutcome['success']) { + return [ + 'success' => true, + 'template_id' => $templateId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $templateOutcome['reason'] ?? "Template '{$templateId}' is not available in the tenant.", + 'warnings' => $warnings, + ]; + } + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => 'Template reference is missing templateFamily and cannot be resolved.', + 'warnings' => $warnings, + ]; + } + + $listOutcome = $this->listTemplatesByFamily($tenant, $templateFamily, $graphOptions); + + if (! $listOutcome['success']) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $listOutcome['reason'] ?? "Unable to list templates for family '{$templateFamily}'.", + 'warnings' => $warnings, + ]; + } + + $candidates = $this->chooseTemplateCandidate( + templates: $listOutcome['templates'], + templateDisplayName: $templateDisplayName, + templateDisplayVersion: $templateDisplayVersion, + ); + + if (count($candidates) !== 1) { + $reason = count($candidates) === 0 + ? "No templates found for family '{$templateFamily}'." + : "Multiple templates found for family '{$templateFamily}' (cannot resolve automatically)."; + + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $reason, + 'warnings' => $warnings, + ]; + } + + $candidate = $candidates[0]; + $resolvedId = is_array($candidate) ? ($candidate['id'] ?? null) : null; + + if (! is_string($resolvedId) || $resolvedId === '') { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => "Template candidate for family '{$templateFamily}' is missing an id.", + 'warnings' => $warnings, + ]; + } + + if ($templateId !== null && $templateId !== $resolvedId) { + $warnings[] = sprintf("TemplateId '%s' not found; mapped to '%s' via templateFamily.", $templateId, $resolvedId); + } + + $templateReference['templateId'] = $resolvedId; + + if (! isset($templateReference['templateDisplayName']) && isset($candidate['displayName'])) { + $templateReference['templateDisplayName'] = $candidate['displayName']; + } + + if (! isset($templateReference['templateDisplayVersion']) && isset($candidate['displayVersion'])) { + $templateReference['templateDisplayVersion'] = $candidate['displayVersion']; + } + + return [ + 'success' => true, + 'template_id' => $resolvedId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,template:?array,reason:?string} + */ + public function getTemplate(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateCache[$tenantKey][$templateId])) { + return $this->templateCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform'])); + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => false, + 'template' => null, + 'reason' => $response->meta['error_message'] ?? 'Template lookup failed.', + ]; + } + + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => true, + 'template' => $response->data, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,templates:array,reason:?string} + */ + public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + $cacheKey = strtolower($templateFamily); + + if (isset($this->familyCache[$tenantKey][$cacheKey])) { + return $this->familyCache[$tenantKey][$cacheKey]; + } + + $escapedFamily = str_replace("'", "''", $templateFamily); + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$filter' => "templateFamily eq '{$escapedFamily}'", + '$top' => 999, + ], + ]); + + $response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context); + + if ($response->failed()) { + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => false, + 'templates' => [], + 'reason' => $response->meta['error_message'] ?? 'Template list failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? array_values(array_filter($value, static fn ($item) => is_array($item))) : []; + + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => true, + 'templates' => $templates, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,definition_ids:array,reason:?string} + */ + public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateDefinitionCache[$tenantKey][$templateId])) { + return $this->templateDefinitionCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$expand' => 'settingDefinitions', + '$top' => 999, + ], + ]); + + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => false, + 'definition_ids' => [], + 'reason' => $response->meta['error_message'] ?? 'Template definitions lookup failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? $value : []; + $definitionIds = []; + + foreach ($templates as $settingTemplate) { + if (! is_array($settingTemplate)) { + continue; + } + + $definitions = $settingTemplate['settingDefinitions'] ?? null; + + if (! is_array($definitions)) { + continue; + } + + foreach ($definitions as $definition) { + if (! is_array($definition)) { + continue; + } + + $id = $definition['id'] ?? null; + + if (is_string($id) && $id !== '') { + $definitionIds[] = $id; + } + } + } + + $definitionIds = array_values(array_unique($definitionIds)); + + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => true, + 'definition_ids' => $definitionIds, + 'reason' => null, + ]; + } + + /** + * @param array $settings + * @return array + */ + public function extractSettingDefinitionIds(array $settings): array + { + $ids = []; + + $walk = function (mixed $node) use (&$walk, &$ids): void { + if (! is_array($node)) { + return; + } + + foreach ($node as $key => $value) { + if (is_string($key) && strtolower($key) === 'settingdefinitionid' && is_string($value) && $value !== '') { + $ids[] = $value; + } + + $walk($value); + } + }; + + $walk($settings); + + return array_values(array_unique($ids)); + } + + /** + * @param array $templates + * @return array + */ + private function chooseTemplateCandidate(array $templates, ?string $templateDisplayName, ?string $templateDisplayVersion): array + { + $candidates = $templates; + + $active = array_values(array_filter($candidates, static function (array $template): bool { + $state = $template['lifecycleState'] ?? null; + + return is_string($state) && strtolower($state) === 'active'; + })); + + if ($active !== []) { + $candidates = $active; + } + + if ($templateDisplayVersion !== null) { + $byVersion = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayVersion): bool { + $version = $template['displayVersion'] ?? null; + + return is_string($version) && $version === $templateDisplayVersion; + })); + + if ($byVersion !== []) { + $candidates = $byVersion; + } + } + + if ($templateDisplayName !== null) { + $byName = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayName): bool { + $name = $template['displayName'] ?? null; + + return is_string($name) && $name === $templateDisplayName; + })); + + if ($byName !== []) { + $candidates = $byName; + } + } + + return $candidates; + } + + /** + * @param array $payload + * @param array $keys + */ + private function extractString(array $payload, array $keys): ?string + { + $normalized = array_map('strtolower', $keys); + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! in_array(strtolower($key), $normalized, true)) { + continue; + } + + if (is_string($value) && trim($value) !== '') { + return $value; + } + } + + return null; + } + + /** + * @param array $graphOptions + */ + private function tenantKey(Tenant $tenant, array $graphOptions): string + { + $tenantId = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + return (string) $tenantId; + } +} diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php index 35383a4..84be502 100644 --- a/app/Services/Intune/RestoreRiskChecker.php +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -16,6 +16,7 @@ class RestoreRiskChecker { public function __construct( private readonly GroupResolver $groupResolver, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, ) {} /** @@ -40,6 +41,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); $results[] = $this->checkMetadataOnlySnapshots($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems); + $results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems); $results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null); @@ -229,6 +231,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array ]; } + /** + * Validate that Endpoint Security policy templates referenced by snapshots exist in the tenant. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkEndpointSecurityTemplates(Tenant $tenant, Collection $policyItems): ?array + { + $issues = []; + $hasRestoreEnabled = false; + $graphOptions = $tenant->graphOptions(); + + foreach ($policyItems as $item) { + if ($item->policy_type !== 'endpointSecurityPolicy') { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $payload = is_array($item->payload) ? $item->payload : []; + $templateReference = $payload['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'reason' => 'Missing templateReference in snapshot.', + ]; + + continue; + } + + $outcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($outcome['success'] ?? false)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'template_id' => $templateReference['templateId'] ?? null, + 'template_family' => $templateReference['templateFamily'] ?? null, + 'reason' => $outcome['reason'] ?? 'Template could not be resolved in the tenant.', + ]; + } + } + + if ($issues === []) { + return [ + 'code' => 'endpoint_security_templates', + 'severity' => 'safe', + 'title' => 'Endpoint security templates', + 'message' => 'All referenced Endpoint Security templates are available.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some Endpoint Security templates are missing or cannot be resolved in the tenant.' + : 'Some Endpoint Security templates are missing or cannot be resolved (execution is preview-only).'; + + return [ + 'code' => 'endpoint_security_templates', + 'severity' => $severity, + 'title' => 'Endpoint security templates', + 'message' => $message, + 'meta' => [ + 'count' => count($issues), + 'items' => $this->truncateList($issues, 10), + ], + ]; + } + /** * Detect snapshots that were captured as metadata-only. * @@ -669,7 +756,17 @@ private function resolveRestoreMode(?string $policyType): string { $meta = $this->resolveTypeMeta($policyType); - return (string) ($meta['restore'] ?? 'enabled'); + if ($meta === []) { + return 'preview-only'; + } + + $restore = $meta['restore'] ?? 'enabled'; + + if (! is_string($restore) || $restore === '') { + return 'enabled'; + } + + return $restore; } private function resolveTypeLabel(?string $policyType): string diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index e75040a..b62ed49 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -27,6 +27,7 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, private readonly AssignmentRestoreService $assignmentRestoreService, private readonly FoundationMappingService $foundationMappingService, ) {} @@ -430,12 +431,13 @@ public function execute( $createdPolicyMode = null; $settingsApplyEligible = false; - if ($item->policy_type === 'settingsCatalogPolicy') { + if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) { + $policyType = $item->policy_type; $settings = $this->extractSettingsCatalogSettings($originalPayload); $policyPayload = $this->stripSettingsFromPayload($payload); $response = $this->graphClient->applyPolicy( - $item->policy_type, + $policyType, $item->policy_identifier, $policyPayload, $graphOptions + ['method' => $updateMethod] @@ -443,8 +445,19 @@ public function execute( $settingsApplyEligible = $response->successful(); - if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -488,6 +501,7 @@ public function execute( if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( + policyType: $policyType, policyId: $item->policy_identifier, settings: $settings, graphOptions: $graphOptions, @@ -496,7 +510,18 @@ public function execute( if ($itemStatus === 'manual_required' && $settingsApply !== null && $this->shouldAttemptSettingsCatalogCreate($settingsApply)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -539,14 +564,6 @@ public function execute( ]; } } - } elseif ($settingsApplyEligible && $settings !== []) { - $settingsApply = [ - 'total' => count($settings), - 'applied' => 0, - 'failed' => count($settings), - 'manual_required' => 0, - 'issues' => [], - ]; } } else { if ($item->policy_type === 'appProtectionPolicy') { @@ -659,6 +676,8 @@ public function execute( 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, + 'graph_method' => $response->meta['method'] ?? null, + 'graph_path' => $response->meta['path'] ?? null, ]; $hardFailures++; @@ -914,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array private function resolveRestoreMode(string $policyType): string { $meta = $this->resolveTypeMeta($policyType); + + if ($meta === []) { + return 'preview-only'; + } + $restore = $meta['restore'] ?? 'enabled'; if (! is_string($restore) || $restore === '') { @@ -960,6 +984,10 @@ private function isNotFoundResponse(object $response): bool $code = strtolower((string) ($response->meta['error_code'] ?? '')); $message = strtolower((string) ($response->meta['error_message'] ?? '')); + if ($message !== '' && str_contains($message, 'resource not found for the segment')) { + return false; + } + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { return true; } @@ -1508,15 +1536,16 @@ private function resolveSettingsCatalogSettingId(array $setting): ?string * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array>}, 1: string} */ private function applySettingsCatalogPolicySettings( + string $policyType, string $policyId, array $settings, array $graphOptions, array $context, ): array { - $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); - $path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); - $bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); - $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); + $method = $this->contracts->settingsWriteMethod($policyType); + $path = $this->contracts->settingsWritePath($policyType, $policyId); + $bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType)); + $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType); $buildIssues = function (string $reason) use ($settings): array { $issues = []; @@ -1549,7 +1578,7 @@ private function applySettingsCatalogPolicySettings( ]; } - $sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if (! is_array($sanitized) || $sanitized === []) { return [ @@ -1683,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool * @return array{success:bool,policy_id:?string,response:?object,mode:string} */ private function createSettingsCatalogPolicy( + string $policyType, array $originalPayload, array $settings, array $graphOptions, array $context, string $fallbackName, ): array { - $resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies'; - $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies'; + $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if ($sanitizedSettings === []) { return [ @@ -1747,6 +1777,79 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @param array $originalPayload + * @param array $settings + * @param array $graphOptions + * @param array $context + * @return array + */ + private function prepareEndpointSecurityPolicyForCreate( + Tenant $tenant, + array $originalPayload, + array $settings, + array $graphOptions, + array $context, + ): array { + $templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']); + + if (! is_array($templateReference)) { + throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.'); + } + + $templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($templateOutcome['success'] ?? false)) { + $reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.'; + + throw new \RuntimeException($reason); + } + + $resolvedTemplateId = $templateOutcome['template_id'] ?? null; + $resolvedReference = $templateOutcome['template_reference'] ?? $templateReference; + + if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') { + throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).'); + } + + if (is_array($resolvedReference) && $resolvedReference !== []) { + $originalPayload['templateReference'] = $resolvedReference; + } + + if ($settings === []) { + return $originalPayload; + } + + $definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions); + + if (! ($definitions['success'] ?? false)) { + return $originalPayload; + } + + $templateDefinitionIds = $definitions['definition_ids'] ?? []; + + if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) { + return $originalPayload; + } + + $policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings); + $missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds)); + + if ($missing === []) { + return $originalPayload; + } + + $sample = implode(', ', array_slice($missing, 0, 5)); + $suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : ''; + + throw new \RuntimeException(sprintf( + 'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s', + $resolvedTemplateId, + $sample, + $suffix, + )); + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index ed0536f..b6c35a4 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -143,6 +143,19 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_whitelist' => [ + 'name', + 'description', + ], + 'update_map' => [ + 'displayName' => 'name', + ], + 'update_strip_keys' => [ + 'platforms', + 'technologies', + 'templateReference', + 'assignments', + ], 'member_hydration_strategy' => 'subresource_settings', 'subresources' => [ 'settings' => [ @@ -153,6 +166,13 @@ 'allowed_expand' => [], ], ], + 'settings_write' => [ + 'path_template' => 'deviceManagement/configurationPolicies/{id}/settings', + 'method' => 'POST', + 'bulk' => true, + 'body_shape' => 'collection', + 'fallback_body_shape' => 'wrapped', + ], // Assignments CRUD (standard Graph pattern) 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', @@ -514,6 +534,11 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'isAssigned', + 'templateId', + 'isMigratingToConfigurationPolicy', + ], ], 'mobileApp' => [ 'resource' => 'deviceAppManagement/mobileApps', diff --git a/config/tenantpilot.php b/config/tenantpilot.php index e3d1ec9..1f6f205 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -192,7 +192,7 @@ 'platform' => 'windows', 'endpoint' => 'deviceManagement/configurationPolicies', 'backup' => 'full', - 'restore' => 'preview-only', + 'restore' => 'enabled', 'risk' => 'high', ], [ diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 38a6ce4..0c9e694 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -268,10 +268,16 @@ @if (! empty($item['graph_error_code']))
Code: {{ $item['graph_error_code'] }}
@endif - @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) + @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
Details
+ @if (! empty($item['graph_method'])) +
method: {{ $item['graph_method'] }}
+ @endif + @if (! empty($item['graph_path'])) +
path: {{ $item['graph_path'] }}
+ @endif @if (! empty($item['graph_request_id']))
request-id: {{ $item['graph_request_id'] }}
@endif diff --git a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php new file mode 100644 index 0000000..c19edbc --- /dev/null +++ b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + 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[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore strips non-patchable fields from endpoint security intent updates', function () { + $client = new EndpointSecurityIntentRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'intent-1', + 'policy_type' => 'endpointSecurityIntent', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'intent-1', + '@odata.type' => '#microsoft.graph.deviceManagementIntent', + 'displayName' => 'SPO Account Protection', + 'description' => 'Demo', + 'isAssigned' => false, + 'templateId' => '0f2b5d70-d4e9-4156-8c16-1397eb6c54a5', + 'isMigratingToConfigurationPolicy' => false, + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + + $payload = $client->applyPolicyCalls[0]['payload'] ?? []; + expect($payload)->toBeArray(); + expect($payload)->toHaveKey('displayName'); + expect($payload)->toHaveKey('description'); + expect($payload)->not->toHaveKey('id'); + expect($payload)->not->toHaveKey('isAssigned'); + expect($payload)->not->toHaveKey('templateId'); + expect($payload)->not->toHaveKey('isMigratingToConfigurationPolicy'); +}); diff --git a/tests/Feature/EndpointSecurityPolicyRestore023Test.php b/tests/Feature/EndpointSecurityPolicyRestore023Test.php new file mode 100644 index 0000000..1a4821e --- /dev/null +++ b/tests/Feature/EndpointSecurityPolicyRestore023Test.php @@ -0,0 +1,265 @@ +}> */ + public array $applyPolicyCalls = []; + + /** @var array}> */ + public array $requestCalls = []; + + /** + * @param array $requestMap + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private readonly array $requestMap = [], + ) {} + + 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[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + 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, + 'options' => $options, + ]; + + foreach ($this->requestMap as $needle => $response) { + if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) { + return $response; + } + } + + return new GraphResponse(true, []); + } +} + +test('restore executes endpoint security policy settings via settings endpoint', function () { + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, [])); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'template-1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings'); + + $settingsCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings')) + ->values(); + + expect($settingsCalls)->toHaveCount(1); + expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings'); + + $body = $settingsCalls[0]['options']['json'] ?? null; + expect($body)->toBeArray()->not->toBeEmpty(); + expect($body[0]['settingInstance']['settingDefinitionId'] ?? null) + ->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +}); + +test('restore fails when endpoint security template is missing', function () { + $applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Not found', + ]); + + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient($applyNotFound, [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'missing-template', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('failed'); + + $createCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies') + ->values(); + + expect($createCalls)->toHaveCount(0); +}); + +test('restore risk checks flag missing endpoint security templates as blocking', function () { + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateId' => 'missing-template', + 'templateFamily' => 'endpointSecurityFirewall', + ], + 'settings' => [], + ], + 'assignments' => null, + ]); + + $checker = app(RestoreRiskChecker::class); + $result = $checker->check( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + groupMapping: [], + ); + + $results = collect($result['results'] ?? []); + $templateCheck = $results->firstWhere('code', 'endpoint_security_templates'); + + expect($templateCheck)->not->toBeNull(); + expect($templateCheck['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/PolicyTypes017Test.php b/tests/Feature/PolicyTypes017Test.php index 3c3813d..2d475d8 100644 --- a/tests/Feature/PolicyTypes017Test.php +++ b/tests/Feature/PolicyTypes017Test.php @@ -262,6 +262,6 @@ public function request(string $method, string $path, array $options = []): Grap $byType = collect($preview)->keyBy('policy_type'); expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled'); - expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only'); + expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('enabled'); expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only'); }); diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php new file mode 100644 index 0000000..0222ca2 --- /dev/null +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + 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[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => "Resource not found for the segment 'endpointSecurityPolicy'.", + 'request_id' => 'req-1', + 'client_request_id' => 'client-1', + 'method' => 'PATCH', + 'path' => 'deviceManagement/endpointSecurityPolicy/esp-1', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore results include graph path and method on Graph failures', function () { + $client = new RestoreGraphErrorMetadataGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'settings' => [], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($run->status)->toBe('failed'); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['graph_method'] ?? null)->toBe('PATCH'); + expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1'); +}); diff --git a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php new file mode 100644 index 0000000..f66f7c6 --- /dev/null +++ b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php @@ -0,0 +1,117 @@ +}> */ + public array $applyPolicyCalls = []; + + 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[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => 'Bad request', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +beforeEach(function () { + $this->originalSupportedTypes = config('tenantpilot.supported_policy_types'); + $this->originalSecurityBaselineContract = config('graph_contracts.types.securityBaselinePolicy'); +}); + +afterEach(function () { + config()->set('tenantpilot.supported_policy_types', $this->originalSupportedTypes); + + if (is_array($this->originalSecurityBaselineContract)) { + config()->set('graph_contracts.types.securityBaselinePolicy', $this->originalSecurityBaselineContract); + } +}); + +test('restore skips security baseline policies when type metadata is missing', function () { + $client = new RestoreUnknownTypeGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + static fn (array $type): bool => ($type['type'] ?? null) !== 'securityBaselinePolicy' + )); + + config()->set('tenantpilot.supported_policy_types', $supported); + config()->set('graph_contracts.types.securityBaselinePolicy', []); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'baseline-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'baseline-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Security Baseline Policy', + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(0); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['status'] ?? null)->toBe('skipped'); + expect($result['restore_mode'] ?? null)->toBe('preview-only'); +}); diff --git a/tests/Unit/GraphClientEndpointResolutionTest.php b/tests/Unit/GraphClientEndpointResolutionTest.php index 3c76a8a..532b799 100644 --- a/tests/Unit/GraphClientEndpointResolutionTest.php +++ b/tests/Unit/GraphClientEndpointResolutionTest.php @@ -61,3 +61,40 @@ return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1'); }); }); + +it('uses built-in endpoint mapping for endpoint security policies when config is missing', function () { + config()->set('graph_contracts.types.endpointSecurityPolicy', []); + config()->set('tenantpilot.foundation_types', []); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'E_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'endpointSecurityPolicy', + policyId: 'E_1', + payload: ['name' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + if (! str_contains($request->url(), '/beta/deviceManagement/configurationPolicies/E_1')) { + return false; + } + + return ! str_contains($request->url(), '/beta/deviceManagement/endpointSecurityPolicy/E_1'); + }); +});