TenantAtlas/app/Services/Graph/GraphContractRegistry.php
ahmido 412dd7ad66 feat/017-policy-types-mam-endpoint-security-baselines (#23)
Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data.
Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing).
Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #23
2026-01-03 02:06:35 +00:00

472 lines
14 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'])) {
$original = $query['$select'];
$select = is_array($original)
? $original
: array_map('trim', explode(',', (string) $original));
$filtered = array_values(array_intersect($select, $allowedSelect));
$withoutAnnotations = array_values(array_filter(
$filtered,
static fn ($field) => is_string($field) && ! str_contains($field, '@')
));
if (count($withoutAnnotations) !== count($filtered)) {
$warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).';
$filtered = $withoutAnnotations;
}
if (count($filtered) !== count($select)) {
$warnings[] = 'Trimmed unsupported $select fields for capability safety.';
}
if ($filtered === []) {
unset($query['$select']);
} else {
$query['$select'] = implode(',', $filtered);
}
}
if (! empty($query['$expand'])) {
$original = $query['$expand'];
$expand = is_array($original)
? $original
: [trim((string) $original)];
$expand = array_values(array_filter($expand, static fn ($value) => $value !== ''));
$filtered = array_values(array_intersect($expand, $allowedExpand));
if (count($filtered) !== count($expand)) {
$warnings[] = 'Trimmed unsupported $expand fields for capability safety.';
}
if ($filtered === []) {
unset($query['$expand']);
} else {
$query['$expand'] = implode(',', $filtered);
}
}
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 = $contract['strip_odata'] ?? ($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 subresourcePath(string $policyType, string $subresourceKey, array $replacements = []): ?string
{
$subresources = config("graph_contracts.types.$policyType.subresources", []);
$subresource = $subresources[$subresourceKey] ?? null;
$path = is_array($subresource) ? ($subresource['path'] ?? null) : null;
if (! is_string($path) || $path === '') {
return null;
}
foreach ($replacements as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$path = str_replace($key, urlencode((string) $value), $path);
}
return $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 = null): ?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;
}
if ($settingId === null && str_contains($template, '{settingId}')) {
return null;
}
$path = str_replace('{id}', urlencode($policyId), $template);
if ($settingId !== null) {
$path = str_replace('{settingId}', urlencode($settingId), $path);
}
return $path;
}
public function settingsWriteBodyShape(string $policyType): string
{
$contract = $this->get($policyType);
$write = $contract['settings_write'] ?? null;
$shape = is_array($write) ? ($write['body_shape'] ?? 'collection') : 'collection';
return is_string($shape) && $shape !== '' ? $shape : 'collection';
}
public function settingsWriteFallbackBodyShape(string $policyType): ?string
{
$contract = $this->get($policyType);
$write = $contract['settings_write'] ?? null;
$shape = is_array($write) ? ($write['fallback_body_shape'] ?? null) : null;
if (! is_string($shape) || $shape === '') {
return null;
}
return $shape;
}
public function resourcePath(string $policyType): ?string
{
$resource = $this->get($policyType)['resource'] ?? null;
if (! is_string($resource) || $resource === '') {
return null;
}
return $resource;
}
/**
* 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) {
$normalizedKey = strtolower((string) $key);
if ($normalizedKey === 'id') {
continue;
}
if ($normalizedKey === '@odata.type') {
$existingOdataType = $value;
continue;
}
if ($normalizedKey === 'settinginstance' && is_array($value)) {
$hasSettingInstance = true;
$result['settingInstance'] = $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;
}
}