tenant_id ?? $tenant->external_id; $context = [ 'tenant' => $tenantIdentifier, 'policy_type' => $policy->policy_type, 'policy_id' => $policy->external_id, ]; $this->graphLogger->logRequest('get_policy', $context); try { $options = [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $policy->platform, ]; if ($this->isMetadataOnlyPolicyType($policy->policy_type)) { $select = $this->metadataOnlySelect($policy->policy_type); if ($select !== []) { $options['select'] = $select; } } if ($policy->policy_type === 'deviceCompliancePolicy') { $options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)'; } $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options); } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); return [ 'failure' => [ 'policy_id' => $policy->id, 'reason' => $mapped->getMessage(), 'status' => $mapped->status, ], ]; } $this->graphLogger->logResponse('get_policy', $response, $context); $payload = $response->data['payload'] ?? $response->data; $metadata = Arr::except($response->data, ['payload']); $metadataWarnings = $metadata['warnings'] ?? []; if ($policy->policy_type === 'windowsUpdateRing') { [$payload, $metadata] = $this->hydrateWindowsUpdateRing( tenantIdentifier: $tenantIdentifier, tenant: $tenant, policyId: $policy->external_id, payload: is_array($payload) ? $payload : [], metadata: $metadata, ); } if ($policy->policy_type === 'settingsCatalogPolicy') { [$payload, $metadata] = $this->hydrateSettingsCatalog( tenantIdentifier: $tenantIdentifier, tenant: $tenant, policyId: $policy->external_id, payload: is_array($payload) ? $payload : [], metadata: $metadata ); } 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, tenant: $tenant, policyId: $policy->external_id, payload: is_array($payload) ? $payload : [], metadata: $metadata ); } if ($response->failed()) { $reason = $response->warnings[0] ?? 'Graph request failed'; $failure = [ 'policy_id' => $policy->id, 'reason' => $reason, 'status' => $response->status, ]; if (! config('graph.stub_on_failure')) { return ['failure' => $failure]; } $payload = [ 'id' => $policy->external_id, 'type' => $policy->policy_type, 'source' => 'stub', 'warning' => $reason, ]; $metadataWarnings = $response->warnings ?? [$reason]; } if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) { $payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []); } $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); $odataWarning = Policy::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform); if ($odataWarning) { $metadataWarnings[] = $odataWarning; } if (! empty($metadataWarnings)) { $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } return [ 'payload' => is_array($payload) ? $payload : [], 'metadata' => $metadata, 'warnings' => $metadataWarnings, ]; } /** * Hydrate Windows Update Ring payload via derived type cast to capture * windowsUpdateForBusinessConfiguration-specific properties. * * @return array{0:array,1:array} */ private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { $odataType = $payload['@odata.type'] ?? null; $castSegment = $this->deriveTypeCastSegment($odataType); if ($castSegment === null) { $metadata['properties_hydration'] = 'skipped'; return [$payload, $metadata]; } $castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment); $response = $this->graphClient->request('GET', $castPath, [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, ]); if ($response->failed() || ! is_array($response->data)) { $metadata['properties_hydration'] = 'failed'; return [$payload, $metadata]; } $metadata['properties_hydration'] = 'complete'; return [array_merge($payload, $response->data), $metadata]; } private function deriveTypeCastSegment(mixed $odataType): ?string { if (! is_string($odataType) || $odataType === '') { return null; } if (! str_starts_with($odataType, '#')) { return null; } $segment = ltrim($odataType, '#'); return $segment !== '' ? $segment : null; } private function isMetadataOnlyPolicyType(string $policyType): bool { foreach (config('tenantpilot.supported_policy_types', []) as $type) { if (($type['type'] ?? null) === $policyType) { return ($type['backup'] ?? null) === 'metadata-only'; } } return false; } /** * @return array */ private function metadataOnlySelect(string $policyType): array { $contract = $this->contracts->get($policyType); $allowedSelect = $contract['allowed_select'] ?? []; if (! is_array($allowedSelect)) { return []; } return array_values(array_filter( $allowedSelect, static fn (mixed $key) => is_string($key) && $key !== '@odata.type' )); } private function filterMetadataOnlyPayload(string $policyType, array $payload): array { $contract = $this->contracts->get($policyType); $allowedSelect = $contract['allowed_select'] ?? []; if (! is_array($allowedSelect) || $allowedSelect === []) { return $payload; } $filtered = []; foreach ($allowedSelect as $key) { if (is_string($key) && array_key_exists($key, $payload)) { $filtered[$key] = $payload[$key]; } } return $filtered; } /** * Hydrate settings catalog policies with configuration settings subresource. * * @return array{0:array,1:array} */ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { $strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy'); $settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId); if ($strategy !== 'subresource_settings' || ! $settingsPath) { return [$payload, $metadata]; } $settings = []; $nextPath = $settingsPath; $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; } $data = $response->data; $pageItems = $data['value'] ?? (is_array($data) ? $data : []); $settings = array_merge($settings, $pageItems); $nextLink = $data['@odata.nextLink'] ?? null; if (! $nextLink) { break; } $nextPath = $this->stripGraphBaseUrl((string) $nextLink); } if (! empty($settings)) { $payload['settings'] = $settings; // Extract definition IDs and warm cache (T008-T010) $definitionIds = $this->extractDefinitionIds($settings); $metadata['definition_count'] = count($definitionIds); // Warm cache for definitions (non-blocking) $this->definitionResolver->warmCache($definitionIds); $metadata['definitions_cached'] = true; } $metadata['settings_hydration'] = $hydrationStatus; 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). * * @return array{0:array,1:array} */ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { $existingActions = $payload['scheduledActionsForRule'] ?? null; if (is_array($existingActions) && $existingActions !== []) { $metadata['compliance_actions_hydration'] = 'embedded'; return [$payload, $metadata]; } $path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId)); $options = [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, ]; $actions = []; $nextPath = $path; $hydrationStatus = 'complete'; while ($nextPath) { $response = $this->graphClient->request('GET', $nextPath, $options); if ($response->failed()) { $hydrationStatus = 'failed'; break; } $data = $response->data; $pageItems = $data['value'] ?? (is_array($data) ? $data : []); foreach ($pageItems as $item) { if (is_array($item)) { $actions[] = $item; } } $nextLink = $data['@odata.nextLink'] ?? null; if (! $nextLink) { break; } $nextPath = $this->stripGraphBaseUrl((string) $nextLink); } if (! empty($actions)) { $payload['scheduledActionsForRule'] = $actions; } $metadata['compliance_actions_hydration'] = $hydrationStatus; return [$payload, $metadata]; } /** * Extract all settingDefinitionId from settings array, including nested children. */ private function extractDefinitionIds(array $settings): array { $definitionIds = []; foreach ($settings as $setting) { // Extract definition ID from settingInstance if (isset($setting['settingInstance']['settingDefinitionId'])) { $definitionIds[] = $setting['settingInstance']['settingDefinitionId']; } // Handle groupSettingCollectionInstance with children if (isset($setting['settingInstance']['@odata.type']) && str_contains($setting['settingInstance']['@odata.type'], 'groupSettingCollectionInstance')) { if (isset($setting['settingInstance']['groupSettingCollectionValue'])) { foreach ($setting['settingInstance']['groupSettingCollectionValue'] as $group) { if (isset($group['children'])) { $childIds = $this->extractDefinitionIds($group['children']); $definitionIds = array_merge($definitionIds, $childIds); } } } } } return array_unique($definitionIds); } private function stripGraphBaseUrl(string $nextLink): string { $base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/'); if (str_starts_with($nextLink, $base)) { return ltrim(substr($nextLink, strlen($base)), '/'); } return ltrim($nextLink, '/'); } }