TenantAtlas/app/Services/Graph/GraphContractRegistry.php
2025-12-14 20:23:18 +01:00

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;
}
}