Implements LIST `$expand` parity with GET by forwarding caller-provided, contract-allowlisted expands. Key changes: - Entra Admin Roles scan now requests `expand=principal` for role assignments so `principal.displayName` can render. - `$expand` normalization/sanitization: top-level comma split (commas inside balanced parentheses preserved), trim, dedupe, allowlist exact match, caps (max 10 tokens, max 200 chars/token). - Diagnostics when expands are removed/truncated (non-prod warning, production low-noise). Tests: - Adds/extends unit coverage for Graph contract sanitization, list request shaping, and the EntraAdminRolesReportService. Spec artifacts included under `specs/112-list-expand-parity/`. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #136
750 lines
23 KiB
PHP
750 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Graph;
|
|
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class GraphContractRegistry
|
|
{
|
|
private const MAX_EXPAND_ITEMS = 10;
|
|
|
|
private const MAX_EXPAND_TOKEN_LENGTH = 200;
|
|
|
|
public function probePath(string $key, array $replacements = []): ?string
|
|
{
|
|
$path = config("graph_contracts.probes.$key.path");
|
|
|
|
if (! is_string($path) || $path === '') {
|
|
return null;
|
|
}
|
|
|
|
foreach ($replacements as $placeholder => $value) {
|
|
if (! is_string($placeholder) || $placeholder === '') {
|
|
continue;
|
|
}
|
|
|
|
$path = str_replace($placeholder, urlencode((string) $value), $path);
|
|
}
|
|
|
|
return '/'.ltrim($path, '/');
|
|
}
|
|
|
|
public function directoryGroupsPolicyType(): string
|
|
{
|
|
return 'directoryGroups';
|
|
}
|
|
|
|
public function directoryGroupsListPath(): string
|
|
{
|
|
$resource = $this->resourcePath($this->directoryGroupsPolicyType()) ?? 'groups';
|
|
|
|
return '/'.ltrim($resource, '/');
|
|
}
|
|
|
|
public function directoryRoleDefinitionsPolicyType(): string
|
|
{
|
|
return 'directoryRoleDefinitions';
|
|
}
|
|
|
|
public function directoryRoleDefinitionsListPath(): string
|
|
{
|
|
$resource = $this->resourcePath($this->directoryRoleDefinitionsPolicyType()) ?? 'deviceManagement/roleDefinitions';
|
|
|
|
return '/'.ltrim($resource, '/');
|
|
}
|
|
|
|
public function configurationPolicyTemplatePolicyType(): string
|
|
{
|
|
return 'configurationPolicyTemplate';
|
|
}
|
|
|
|
public function configurationPolicyTemplateListPath(): string
|
|
{
|
|
$resource = $this->resourcePath($this->configurationPolicyTemplatePolicyType()) ?? 'deviceManagement/configurationPolicyTemplates';
|
|
|
|
return '/'.ltrim($resource, '/');
|
|
}
|
|
|
|
public function configurationPolicyTemplateItemPath(string $templateId): string
|
|
{
|
|
return sprintf('%s/%s', $this->configurationPolicyTemplateListPath(), urlencode($templateId));
|
|
}
|
|
|
|
public function configurationPolicyTemplateSettingTemplatesPath(string $templateId): string
|
|
{
|
|
$path = $this->subresourcePath(
|
|
$this->configurationPolicyTemplatePolicyType(),
|
|
'settingTemplates',
|
|
['{id}' => $templateId]
|
|
);
|
|
|
|
if (! is_string($path) || $path === '') {
|
|
return sprintf('%s/%s/settingTemplates', $this->configurationPolicyTemplateListPath(), urlencode($templateId));
|
|
}
|
|
|
|
return '/'.ltrim($path, '/');
|
|
}
|
|
|
|
public function settingsCatalogDefinitionPolicyType(): string
|
|
{
|
|
return 'settingsCatalogDefinition';
|
|
}
|
|
|
|
public function settingsCatalogDefinitionListPath(): string
|
|
{
|
|
$resource = $this->resourcePath($this->settingsCatalogDefinitionPolicyType()) ?? 'deviceManagement/configurationSettings';
|
|
|
|
return '/'.ltrim($resource, '/');
|
|
}
|
|
|
|
public function settingsCatalogDefinitionItemPath(string $definitionId): string
|
|
{
|
|
return sprintf('%s/%s', $this->settingsCatalogDefinitionListPath(), urlencode($definitionId));
|
|
}
|
|
|
|
public function settingsCatalogCategoryPolicyType(): string
|
|
{
|
|
return 'settingsCatalogCategory';
|
|
}
|
|
|
|
public function settingsCatalogCategoryListPath(): string
|
|
{
|
|
$resource = $this->resourcePath($this->settingsCatalogCategoryPolicyType()) ?? 'deviceManagement/configurationCategories';
|
|
|
|
return '/'.ltrim($resource, '/');
|
|
}
|
|
|
|
public function settingsCatalogCategoryItemPath(string $categoryId): string
|
|
{
|
|
return sprintf('%s/%s', $this->settingsCatalogCategoryListPath(), urlencode($categoryId));
|
|
}
|
|
|
|
public function rbacRoleAssignmentPolicyType(): string
|
|
{
|
|
return 'rbacRoleAssignment';
|
|
}
|
|
|
|
public function rbacRoleAssignmentListPath(): string
|
|
{
|
|
$resource = $this->resourcePath($this->rbacRoleAssignmentPolicyType()) ?? 'deviceManagement/roleAssignments';
|
|
|
|
return '/'.ltrim($resource, '/');
|
|
}
|
|
|
|
public function rbacRoleAssignmentItemPath(string $assignmentId): string
|
|
{
|
|
return sprintf('%s/%s', $this->rbacRoleAssignmentListPath(), urlencode($assignmentId));
|
|
}
|
|
|
|
/**
|
|
* @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'];
|
|
$allowedExpand = is_array($allowedExpand) ? $allowedExpand : [];
|
|
$requested = $this->normalizeExpandInput($original);
|
|
|
|
$tooLong = array_values(array_filter(
|
|
$requested,
|
|
static fn (string $token): bool => strlen($token) > self::MAX_EXPAND_TOKEN_LENGTH,
|
|
));
|
|
|
|
$bounded = array_values(array_filter(
|
|
$requested,
|
|
static fn (string $token): bool => strlen($token) <= self::MAX_EXPAND_TOKEN_LENGTH,
|
|
));
|
|
|
|
if ($tooLong !== []) {
|
|
$warnings[] = 'Trimmed overly long $expand fields for capability safety.';
|
|
}
|
|
|
|
$allowed = array_values(array_intersect($bounded, $allowedExpand));
|
|
$disallowed = array_values(array_diff($bounded, $allowed));
|
|
|
|
if ($disallowed !== []) {
|
|
$warnings[] = 'Trimmed unsupported $expand fields for capability safety.';
|
|
}
|
|
|
|
$truncated = [];
|
|
if (count($allowed) > self::MAX_EXPAND_ITEMS) {
|
|
$truncated = array_slice($allowed, self::MAX_EXPAND_ITEMS);
|
|
$allowed = array_slice($allowed, 0, self::MAX_EXPAND_ITEMS);
|
|
$warnings[] = sprintf('Trimmed $expand fields to a maximum of %d items for capability safety.', self::MAX_EXPAND_ITEMS);
|
|
}
|
|
|
|
if ($tooLong !== [] || $disallowed !== [] || $truncated !== []) {
|
|
$this->logSanitizedExpand($policyType, $requested, $allowed, [
|
|
'too_long' => $tooLong,
|
|
'disallowed' => $disallowed,
|
|
'max_items' => $truncated,
|
|
]);
|
|
}
|
|
|
|
if ($allowed === []) {
|
|
unset($query['$expand']);
|
|
} else {
|
|
$query['$expand'] = implode(',', $allowed);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'query' => $query,
|
|
'warnings' => $warnings,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Normalize $expand input into an ordered, de-duped list of tokens.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizeExpandInput(mixed $original): array
|
|
{
|
|
$tokens = is_array($original)
|
|
? $original
|
|
: $this->splitOnTopLevelCommas((string) $original);
|
|
|
|
$normalized = [];
|
|
$seen = [];
|
|
|
|
foreach ($tokens as $token) {
|
|
if (! is_string($token)) {
|
|
continue;
|
|
}
|
|
|
|
$token = trim($token);
|
|
|
|
if ($token === '') {
|
|
continue;
|
|
}
|
|
|
|
if (isset($seen[$token])) {
|
|
continue;
|
|
}
|
|
|
|
$seen[$token] = true;
|
|
$normalized[] = $token;
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* Split comma-separated strings on top-level commas only, preserving commas
|
|
* inside balanced parentheses.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
private function splitOnTopLevelCommas(string $value): array
|
|
{
|
|
$depth = 0;
|
|
$buffer = '';
|
|
$tokens = [];
|
|
$length = strlen($value);
|
|
|
|
for ($index = 0; $index < $length; $index++) {
|
|
$character = $value[$index];
|
|
|
|
if ($character === '(') {
|
|
$depth++;
|
|
$buffer .= $character;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($character === ')') {
|
|
$depth = max(0, $depth - 1);
|
|
$buffer .= $character;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($character === ',' && $depth === 0) {
|
|
$tokens[] = $buffer;
|
|
$buffer = '';
|
|
|
|
continue;
|
|
}
|
|
|
|
$buffer .= $character;
|
|
}
|
|
|
|
$tokens[] = $buffer;
|
|
|
|
return $tokens;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $requested
|
|
* @param array<int, string> $allowed
|
|
* @param array{too_long: array<int, string>, disallowed: array<int, string>, max_items: array<int, string>} $removedByReason
|
|
*/
|
|
private function logSanitizedExpand(string $policyType, array $requested, array $allowed, array $removedByReason): void
|
|
{
|
|
$removed = array_values(array_merge(
|
|
$removedByReason['too_long'],
|
|
$removedByReason['disallowed'],
|
|
$removedByReason['max_items'],
|
|
));
|
|
|
|
if ($removed === []) {
|
|
return;
|
|
}
|
|
|
|
$context = [
|
|
'policy_type' => $policyType,
|
|
'query_key' => '$expand',
|
|
'requested' => $requested,
|
|
'allowed' => $allowed,
|
|
'removed' => $removed,
|
|
'removed_by_reason' => array_filter($removedByReason, static fn (array $values): bool => $values !== []),
|
|
'max_items' => self::MAX_EXPAND_ITEMS,
|
|
'max_token_len' => self::MAX_EXPAND_TOKEN_LENGTH,
|
|
];
|
|
|
|
if (app()->isProduction()) {
|
|
Log::debug('Graph query sanitized', $context);
|
|
|
|
return;
|
|
}
|
|
|
|
Log::warning('Graph query sanitized', $context);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|