*/ public function get(string $policyType): array { return config("graph_contracts.types.$policyType", []); } /** * @param array $query * @return array{query: array, warnings: array} */ public function sanitizeQuery(string $policyType, array $query): array { $contract = $this->get($policyType); $allowedSelect = $contract['allowed_select'] ?? []; $allowedExpand = $contract['allowed_expand'] ?? []; $warnings = []; if (! empty($query['$select']) && is_array($query['$select'])) { $original = $query['$select']; $query['$select'] = array_values(array_intersect($original, $allowedSelect)); if (count($query['$select']) !== count($original)) { $warnings[] = 'Trimmed unsupported $select fields for capability safety.'; } } if (! empty($query['$expand']) && is_array($query['$expand'])) { $original = $query['$expand']; $query['$expand'] = array_values(array_intersect($original, $allowedExpand)); if (count($query['$expand']) !== count($original)) { $warnings[] = 'Trimmed unsupported $expand fields for capability safety.'; } } return [ 'query' => $query, 'warnings' => $warnings, ]; } public function matchesTypeFamily(string $policyType, ?string $odataType): bool { if ($odataType === null) { return false; } $family = config("graph_contracts.types.$policyType.type_family", []); return in_array(strtolower($odataType), array_map('strtolower', $family), true); } /** * Sanitize update payloads based on contract metadata. */ public function sanitizeUpdatePayload(string $policyType, array $snapshot): array { $contract = $this->get($policyType); $whitelist = $contract['update_whitelist'] ?? null; $stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []); $mapping = $contract['update_map'] ?? []; $stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']); $result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping); return $result; } public function subresourceSettingsPath(string $policyType, string $policyId): ?string { $subresources = config("graph_contracts.types.$policyType.subresources", []); $settings = $subresources['settings'] ?? null; $path = $settings['path'] ?? null; if (! $path) { return null; } return str_replace('{id}', urlencode($policyId), $path); } public function settingsWriteMethod(string $policyType): ?string { $contract = $this->get($policyType); $write = $contract['settings_write'] ?? null; $method = is_array($write) ? ($write['method'] ?? null) : null; if (! is_string($method) || $method === '') { return null; } return strtoupper($method); } public function settingsWritePath(string $policyType, string $policyId, string $settingId): ?string { $contract = $this->get($policyType); $write = $contract['settings_write'] ?? null; $template = is_array($write) ? ($write['path_template'] ?? null) : null; if (! is_string($template) || $template === '') { return null; } return str_replace( ['{id}', '{settingId}'], [urlencode($policyId), urlencode($settingId)], $template ); } /** * Sanitize a settings_apply payload for settingsCatalogPolicy. * Preserves `@odata.type` inside `settingInstance` and nested children while * stripping read-only/meta fields and ids that the server may reject. * * @param array|array $settings * @return array */ public function sanitizeSettingsApplyPayload(string $policyType, array $settings): array { $clean = []; foreach ($settings as $item) { if (! is_array($item)) { continue; } $clean[] = $this->sanitizeSettingsItem($item); } return $clean; } private function sanitizeSettingsItem(array $item): array { $result = []; $hasSettingInstance = false; $existingOdataType = null; // First pass: collect information and process items foreach ($item as $key => $value) { if (strtolower($key) === 'id') { continue; } if ($key === '@odata.type') { $existingOdataType = $value; continue; } if ($key === 'settingInstance' && is_array($value)) { $hasSettingInstance = true; $result[$key] = $this->preserveOdataTypesRecursively($value); continue; } // For arrays, recurse into members but keep @odata.type where present if (is_array($value)) { $result[$key] = $this->sanitizeArray($value, null, $this->readOnlyKeys(), false, []); continue; } $result[$key] = $value; } // Ensure top-level @odata.type is present and FIRST for Settings Catalog settings // Microsoft Graph requires this to properly interpret the settingInstance type $odataType = $existingOdataType ?? ($hasSettingInstance ? '#microsoft.graph.deviceManagementConfigurationSetting' : null); if ($odataType) { // Prepend @odata.type to ensure it appears first in JSON $result = ['@odata.type' => $odataType] + $result; } return $result; } /** * Recursively preserve `@odata.type` keys inside settingInstance structures * while stripping read-only keys and ids from nested objects/arrays. * * @param array $node * @return array */ private function preserveOdataTypesRecursively(array $node): array { $clean = []; foreach ($node as $key => $value) { $lower = strtolower((string) $key); // strip id fields if ($lower === 'id') { continue; } if ($key === '@odata.type') { $clean[$key] = $value; continue; } if (is_array($value)) { if (array_is_list($value)) { $clean[$key] = array_values(array_map(function ($child) { if (is_array($child)) { return $this->preserveOdataTypesRecursively($child); } return $child; }, $value)); continue; } $clean[$key] = $this->preserveOdataTypesRecursively($value); continue; } $clean[$key] = $value; } return $clean; } public function memberHydrationStrategy(string $policyType): ?string { return config("graph_contracts.types.$policyType.member_hydration_strategy"); } /** * Determine whether a failed response qualifies for capability downgrade retry. */ public function shouldDowngradeOnCapabilityError(GraphResponse $response, array $query): bool { if (empty($query)) { return false; } if ($response->status !== 400) { return false; } $message = strtolower($response->meta['error_message'] ?? $this->firstErrorMessage($response->errors)); if ($message && (str_contains($message, '$select') || str_contains($message, '$expand') || str_contains($message, 'request is invalid'))) { return true; } return ! empty($query['$select']) || ! empty($query['$expand']); } private function sanitizeArray(array $payload, ?array $whitelist, array $stripKeys, bool $stripOdata = false, array $mapping = []): array { $clean = []; $normalizedWhitelist = $whitelist ? array_map('strtolower', $whitelist) : null; $normalizedStrip = array_map('strtolower', $stripKeys); $normalizedMapping = []; foreach ($mapping as $source => $target) { $normalizedMapping[strtolower($source)] = $target; } foreach ($payload as $key => $value) { $normalizedKey = strtolower((string) $key); $targetKey = $normalizedMapping[$normalizedKey] ?? $key; $normalizedTargetKey = strtolower((string) $targetKey); if ($normalizedWhitelist !== null) { $targetKey = $normalizedTargetKey; } if ($this->shouldStripKey($normalizedKey, $normalizedStrip, $stripOdata)) { continue; } if ($normalizedWhitelist !== null && ! in_array($normalizedTargetKey, $normalizedWhitelist, true)) { continue; } if (is_array($value)) { if (array_is_list($value)) { $clean[$targetKey] = array_values(array_filter(array_map( fn ($item) => is_array($item) ? $this->sanitizeArray($item, null, $stripKeys, $stripOdata, $mapping) : $item, $value ), fn ($item) => $item !== [])); continue; } $clean[$targetKey] = $this->sanitizeArray($value, null, $stripKeys, $stripOdata, $mapping); continue; } $clean[$targetKey] = $value; } return $clean; } private function shouldStripKey(string $normalizedKey, array $normalizedStrip, bool $stripOdata): bool { if (in_array($normalizedKey, $normalizedStrip, true)) { return true; } if ($stripOdata && str_starts_with($normalizedKey, '@odata')) { return true; } if ($stripOdata && str_contains($normalizedKey, '@odata.')) { return true; } if ($stripOdata && str_contains($normalizedKey, '@odata')) { return true; } return false; } /** * @return array */ private function readOnlyKeys(): array { return [ '@odata.context', '@odata.etag', '@odata.nextlink', '@odata.deltalink', 'id', 'createddatetime', 'lastmodifieddatetime', 'version', 'supportsscopetags', 'createdby', 'lastmodifiedby', 'rolescopetagids@odata.bind', 'rolescopetagids@odata.navigationlink', 'rolescopetagids@odata.type', 'rolescopetagids', ]; } private function firstErrorMessage(array $errors): ?string { foreach ($errors as $error) { if (is_array($error) && is_string(Arr::get($error, 'message'))) { return Arr::get($error, 'message'); } if (is_array($error) && is_string(Arr::get($error, 'error.message'))) { return Arr::get($error, 'error.message'); } if (is_string($error)) { return $error; } } return null; } }