382 lines
12 KiB
PHP
382 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Graph;
|
|
|
|
use Illuminate\Support\Arr;
|
|
|
|
class GraphContractRegistry
|
|
{
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function get(string $policyType): array
|
|
{
|
|
return config("graph_contracts.types.$policyType", []);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $query
|
|
* @return array{query: array<string, mixed>, warnings: array<int, string>}
|
|
*/
|
|
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<int, mixed>|array<string,mixed> $settings
|
|
* @return array<int, mixed>
|
|
*/
|
|
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<string,mixed> $node
|
|
* @return array<string,mixed>
|
|
*/
|
|
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<int, string>
|
|
*/
|
|
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;
|
|
}
|
|
}
|