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
714 lines
25 KiB
PHP
714 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Intune;
|
|
|
|
use App\Models\Policy;
|
|
use App\Models\Tenant;
|
|
use App\Services\Graph\GraphClientInterface;
|
|
use App\Services\Graph\GraphContractRegistry;
|
|
use App\Services\Graph\GraphErrorMapper;
|
|
use App\Services\Graph\GraphLogger;
|
|
use App\Services\Graph\GraphResponse;
|
|
use Illuminate\Support\Arr;
|
|
use Throwable;
|
|
|
|
class PolicySnapshotService
|
|
{
|
|
public function __construct(
|
|
private readonly GraphClientInterface $graphClient,
|
|
private readonly GraphLogger $graphLogger,
|
|
private readonly GraphContractRegistry $contracts,
|
|
private readonly SnapshotValidator $snapshotValidator,
|
|
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
|
|
) {}
|
|
|
|
/**
|
|
* Fetch a policy snapshot from Graph (with optional hydration) for backup/version capture.
|
|
*
|
|
* @return array{payload:array,metadata:array,warnings:array}|array{failure:array}
|
|
*/
|
|
public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null): array
|
|
{
|
|
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
|
|
|
|
$context = [
|
|
'tenant' => $tenantIdentifier,
|
|
'policy_type' => $policy->policy_type,
|
|
'policy_id' => $policy->external_id,
|
|
];
|
|
|
|
$this->graphLogger->logRequest('get_policy', $context);
|
|
|
|
try {
|
|
$options = [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
'platform' => $policy->platform,
|
|
];
|
|
|
|
if ($this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
|
$select = $this->metadataOnlySelect($policy->policy_type);
|
|
|
|
if ($select !== []) {
|
|
$options['select'] = $select;
|
|
}
|
|
}
|
|
|
|
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
|
$options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)';
|
|
}
|
|
|
|
$response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options);
|
|
} catch (Throwable $throwable) {
|
|
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
|
|
|
|
// For certain policy types experiencing upstream Graph issues, fall back to metadata-only
|
|
if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) {
|
|
return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status);
|
|
}
|
|
|
|
return [
|
|
'failure' => [
|
|
'policy_id' => $policy->id,
|
|
'reason' => $mapped->getMessage(),
|
|
'status' => $mapped->status,
|
|
],
|
|
];
|
|
}
|
|
|
|
$this->graphLogger->logResponse('get_policy', $response, $context);
|
|
|
|
$payload = $response->data['payload'] ?? $response->data;
|
|
$metadata = Arr::except($response->data, ['payload']);
|
|
$metadataWarnings = $metadata['warnings'] ?? [];
|
|
|
|
if ($policy->policy_type === 'windowsUpdateRing') {
|
|
[$payload, $metadata] = $this->hydrateWindowsUpdateRing(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata,
|
|
);
|
|
}
|
|
|
|
if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) {
|
|
[$payload, $metadata] = $this->hydrateConfigurationPolicySettings(
|
|
policyType: $policy->policy_type,
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata
|
|
);
|
|
}
|
|
|
|
if ($policy->policy_type === 'groupPolicyConfiguration') {
|
|
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata
|
|
);
|
|
}
|
|
|
|
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
|
[$payload, $metadata] = $this->hydrateComplianceActions(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata
|
|
);
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
$reason = $this->formatGraphFailureReason($response);
|
|
|
|
if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) {
|
|
return $this->createMetadataOnlySnapshot($policy, $reason, $response->status);
|
|
}
|
|
|
|
$failure = [
|
|
'policy_id' => $policy->id,
|
|
'reason' => $reason,
|
|
'status' => $response->status,
|
|
];
|
|
|
|
if (! config('graph.stub_on_failure')) {
|
|
return ['failure' => $failure];
|
|
}
|
|
|
|
$payload = [
|
|
'id' => $policy->external_id,
|
|
'type' => $policy->policy_type,
|
|
'source' => 'stub',
|
|
'warning' => $reason,
|
|
];
|
|
$metadataWarnings = $response->warnings ?? [$reason];
|
|
}
|
|
|
|
if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) {
|
|
$payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []);
|
|
}
|
|
|
|
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
|
|
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
|
|
|
|
$odataWarning = Policy::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform);
|
|
|
|
if ($odataWarning) {
|
|
$metadataWarnings[] = $odataWarning;
|
|
}
|
|
|
|
if (! empty($metadataWarnings)) {
|
|
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
|
|
}
|
|
|
|
return [
|
|
'payload' => is_array($payload) ? $payload : [],
|
|
'metadata' => $metadata,
|
|
'warnings' => $metadataWarnings,
|
|
];
|
|
}
|
|
|
|
private function formatGraphFailureReason(GraphResponse $response): string
|
|
{
|
|
$code = $response->meta['error_code']
|
|
?? ($response->errors[0]['code'] ?? null)
|
|
?? ($response->data['error']['code'] ?? null);
|
|
|
|
$message = $response->meta['error_message']
|
|
?? ($response->errors[0]['message'] ?? null)
|
|
?? ($response->data['error']['message'] ?? null)
|
|
?? ($response->warnings[0] ?? null);
|
|
|
|
$reason = 'Graph request failed';
|
|
|
|
if (is_string($message) && $message !== '') {
|
|
$reason = $message;
|
|
}
|
|
|
|
if (is_string($code) && $code !== '') {
|
|
$reason = sprintf('%s: %s', $code, $reason);
|
|
}
|
|
|
|
$requestId = $response->meta['request_id'] ?? null;
|
|
$clientRequestId = $response->meta['client_request_id'] ?? null;
|
|
|
|
$suffixParts = [];
|
|
|
|
if (is_string($clientRequestId) && $clientRequestId !== '') {
|
|
$suffixParts[] = sprintf('client_request_id=%s', $clientRequestId);
|
|
}
|
|
|
|
if (is_string($requestId) && $requestId !== '') {
|
|
$suffixParts[] = sprintf('request_id=%s', $requestId);
|
|
}
|
|
|
|
if ($suffixParts !== []) {
|
|
$reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts));
|
|
}
|
|
|
|
return $reason;
|
|
}
|
|
|
|
/**
|
|
* Hydrate Windows Update Ring payload via derived type cast to capture
|
|
* windowsUpdateForBusinessConfiguration-specific properties.
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$odataType = $payload['@odata.type'] ?? null;
|
|
$castSegment = $this->deriveTypeCastSegment($odataType);
|
|
|
|
if ($castSegment === null) {
|
|
$metadata['properties_hydration'] = 'skipped';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment);
|
|
|
|
$response = $this->graphClient->request('GET', $castPath, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($response->failed() || ! is_array($response->data)) {
|
|
$metadata['properties_hydration'] = 'failed';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$metadata['properties_hydration'] = 'complete';
|
|
|
|
return [array_merge($payload, $response->data), $metadata];
|
|
}
|
|
|
|
private function deriveTypeCastSegment(mixed $odataType): ?string
|
|
{
|
|
if (! is_string($odataType) || $odataType === '') {
|
|
return null;
|
|
}
|
|
|
|
if (! str_starts_with($odataType, '#')) {
|
|
return null;
|
|
}
|
|
|
|
$segment = ltrim($odataType, '#');
|
|
|
|
return $segment !== '' ? $segment : null;
|
|
}
|
|
|
|
private function isMetadataOnlyPolicyType(string $policyType): bool
|
|
{
|
|
foreach (config('tenantpilot.supported_policy_types', []) as $type) {
|
|
if (($type['type'] ?? null) === $policyType) {
|
|
return ($type['backup'] ?? null) === 'metadata-only';
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function metadataOnlySelect(string $policyType): array
|
|
{
|
|
$contract = $this->contracts->get($policyType);
|
|
$allowedSelect = $contract['allowed_select'] ?? [];
|
|
|
|
if (! is_array($allowedSelect)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$allowedSelect,
|
|
static fn (mixed $key) => is_string($key) && $key !== '@odata.type'
|
|
));
|
|
}
|
|
|
|
private function filterMetadataOnlyPayload(string $policyType, array $payload): array
|
|
{
|
|
$contract = $this->contracts->get($policyType);
|
|
$allowedSelect = $contract['allowed_select'] ?? [];
|
|
|
|
if (! is_array($allowedSelect) || $allowedSelect === []) {
|
|
return $payload;
|
|
}
|
|
|
|
$filtered = [];
|
|
|
|
foreach ($allowedSelect as $key) {
|
|
if (is_string($key) && array_key_exists($key, $payload)) {
|
|
$filtered[$key] = $payload[$key];
|
|
}
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
/**
|
|
* Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines).
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$strategy = $this->contracts->memberHydrationStrategy($policyType);
|
|
$settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId);
|
|
|
|
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$settings = [];
|
|
$nextPath = $settingsPath;
|
|
$hydrationStatus = 'complete';
|
|
|
|
while ($nextPath) {
|
|
$response = $this->graphClient->request('GET', $nextPath, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
$hydrationStatus = 'failed';
|
|
|
|
break;
|
|
}
|
|
|
|
$data = $response->data;
|
|
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
|
$settings = array_merge($settings, $pageItems);
|
|
$nextLink = $data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $nextLink) {
|
|
break;
|
|
}
|
|
|
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
|
}
|
|
|
|
if (! empty($settings)) {
|
|
$payload['settings'] = $settings;
|
|
|
|
// Extract definition IDs and warm cache (T008-T010)
|
|
$definitionIds = $this->extractDefinitionIds($settings);
|
|
$metadata['definition_count'] = count($definitionIds);
|
|
|
|
// Warm cache for definitions (non-blocking)
|
|
$this->definitionResolver->warmCache($definitionIds);
|
|
$metadata['definitions_cached'] = true;
|
|
}
|
|
|
|
$metadata['settings_hydration'] = $hydrationStatus;
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* Hydrate Administrative Templates (Group Policy Configurations) with definitionValues and presentationValues.
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
|
|
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
|
|
'{id}' => $policyId,
|
|
]);
|
|
|
|
if ($strategy !== 'subresource_definition_values' || ! $definitionValuesPath) {
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$graphBase = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
|
|
.'/'.trim((string) config('graph.version', 'beta'), '/');
|
|
$definitionValues = [];
|
|
$nextPath = $definitionValuesPath;
|
|
$hydrationStatus = 'complete';
|
|
|
|
while ($nextPath) {
|
|
$response = $this->graphClient->request('GET', $nextPath, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
$hydrationStatus = 'failed';
|
|
break;
|
|
}
|
|
|
|
$definitionValues = array_merge($definitionValues, $response->data['value'] ?? []);
|
|
$nextLink = $response->data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $nextLink) {
|
|
break;
|
|
}
|
|
|
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
|
}
|
|
|
|
if ($hydrationStatus === 'failed') {
|
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
|
$metadata['warnings'] ?? [],
|
|
['Hydration failed: could not load Administrative Templates definition values.']
|
|
)));
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$settings = [];
|
|
|
|
foreach ($definitionValues as $definitionValue) {
|
|
if (! is_array($definitionValue)) {
|
|
continue;
|
|
}
|
|
|
|
$definition = $definitionValue['definition'] ?? null;
|
|
$definitionId = is_array($definition) ? ($definition['id'] ?? null) : null;
|
|
$definitionValueId = $definitionValue['id'] ?? null;
|
|
|
|
if (! is_string($definitionValueId) || $definitionValueId === '') {
|
|
continue;
|
|
}
|
|
|
|
if (! is_string($definitionId) || $definitionId === '') {
|
|
continue;
|
|
}
|
|
|
|
$presentationValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'presentationValues', [
|
|
'{id}' => $policyId,
|
|
'{definitionValueId}' => $definitionValueId,
|
|
]);
|
|
|
|
$setting = [
|
|
'enabled' => (bool) ($definitionValue['enabled'] ?? false),
|
|
'definition@odata.bind' => "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')",
|
|
'#Definition_Id' => $definitionId,
|
|
'#Definition_displayName' => is_array($definition) ? ($definition['displayName'] ?? null) : null,
|
|
'#Definition_classType' => is_array($definition) ? ($definition['classType'] ?? null) : null,
|
|
'#Definition_categoryPath' => is_array($definition) ? ($definition['categoryPath'] ?? null) : null,
|
|
];
|
|
|
|
$setting = array_filter($setting, static fn ($value) => $value !== null);
|
|
|
|
if (! $presentationValuesPath) {
|
|
$settings[] = $setting;
|
|
|
|
continue;
|
|
}
|
|
|
|
$presentationValues = [];
|
|
$presentationNext = $presentationValuesPath;
|
|
|
|
while ($presentationNext) {
|
|
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($pvResponse->failed()) {
|
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
|
$metadata['warnings'] ?? [],
|
|
['Hydration warning: could not load some Administrative Templates presentation values.']
|
|
)));
|
|
break;
|
|
}
|
|
|
|
$presentationValues = array_merge($presentationValues, $pvResponse->data['value'] ?? []);
|
|
$presentationNextLink = $pvResponse->data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $presentationNextLink) {
|
|
break;
|
|
}
|
|
|
|
$presentationNext = $this->stripGraphBaseUrl((string) $presentationNextLink);
|
|
}
|
|
|
|
if ($presentationValues !== []) {
|
|
$setting['presentationValues'] = [];
|
|
|
|
foreach ($presentationValues as $presentationValue) {
|
|
if (! is_array($presentationValue)) {
|
|
continue;
|
|
}
|
|
|
|
$presentation = $presentationValue['presentation'] ?? null;
|
|
$presentationId = is_array($presentation) ? ($presentation['id'] ?? null) : null;
|
|
|
|
if (! is_string($presentationId) || $presentationId === '') {
|
|
continue;
|
|
}
|
|
|
|
$cleanPresentationValue = Arr::except($presentationValue, [
|
|
'presentation',
|
|
'id',
|
|
'lastModifiedDateTime',
|
|
'createdDateTime',
|
|
]);
|
|
|
|
$cleanPresentationValue['presentation@odata.bind'] = "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')/presentations('{$presentationId}')";
|
|
|
|
$label = is_array($presentation) ? ($presentation['label'] ?? null) : null;
|
|
|
|
if (is_string($label) && $label !== '') {
|
|
$cleanPresentationValue['#Presentation_Label'] = $label;
|
|
}
|
|
|
|
$cleanPresentationValue['#Presentation_Id'] = $presentationId;
|
|
|
|
$setting['presentationValues'][] = $cleanPresentationValue;
|
|
}
|
|
|
|
if ($setting['presentationValues'] === []) {
|
|
unset($setting['presentationValues']);
|
|
}
|
|
}
|
|
|
|
$settings[] = $setting;
|
|
}
|
|
|
|
$payload['definitionValues'] = $settings;
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* Hydrate compliance policies with scheduled actions (notification templates).
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$existingActions = $payload['scheduledActionsForRule'] ?? null;
|
|
|
|
if (is_array($existingActions) && $existingActions !== []) {
|
|
$metadata['compliance_actions_hydration'] = 'embedded';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
|
|
$options = [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
];
|
|
|
|
$actions = [];
|
|
$nextPath = $path;
|
|
$hydrationStatus = 'complete';
|
|
|
|
while ($nextPath) {
|
|
$response = $this->graphClient->request('GET', $nextPath, $options);
|
|
|
|
if ($response->failed()) {
|
|
$hydrationStatus = 'failed';
|
|
|
|
break;
|
|
}
|
|
|
|
$data = $response->data;
|
|
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
|
|
|
foreach ($pageItems as $item) {
|
|
if (is_array($item)) {
|
|
$actions[] = $item;
|
|
}
|
|
}
|
|
|
|
$nextLink = $data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $nextLink) {
|
|
break;
|
|
}
|
|
|
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
|
}
|
|
|
|
if (! empty($actions)) {
|
|
$payload['scheduledActionsForRule'] = $actions;
|
|
}
|
|
|
|
$metadata['compliance_actions_hydration'] = $hydrationStatus;
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* Extract all settingDefinitionId from settings array, including nested children.
|
|
*/
|
|
private function extractDefinitionIds(array $settings): array
|
|
{
|
|
$definitionIds = [];
|
|
|
|
foreach ($settings as $setting) {
|
|
// Extract definition ID from settingInstance
|
|
if (isset($setting['settingInstance']['settingDefinitionId'])) {
|
|
$definitionIds[] = $setting['settingInstance']['settingDefinitionId'];
|
|
}
|
|
|
|
// Handle groupSettingCollectionInstance with children
|
|
if (isset($setting['settingInstance']['@odata.type']) &&
|
|
str_contains($setting['settingInstance']['@odata.type'], 'groupSettingCollectionInstance')) {
|
|
if (isset($setting['settingInstance']['groupSettingCollectionValue'])) {
|
|
foreach ($setting['settingInstance']['groupSettingCollectionValue'] as $group) {
|
|
if (isset($group['children'])) {
|
|
$childIds = $this->extractDefinitionIds($group['children']);
|
|
$definitionIds = array_merge($definitionIds, $childIds);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_unique($definitionIds);
|
|
}
|
|
|
|
private function stripGraphBaseUrl(string $nextLink): string
|
|
{
|
|
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/');
|
|
|
|
if (str_starts_with($nextLink, $base)) {
|
|
return ltrim(substr($nextLink, strlen($base)), '/');
|
|
}
|
|
|
|
return $nextLink;
|
|
}
|
|
|
|
/**
|
|
* Determine if we should fall back to metadata-only for this policy type and error.
|
|
*/
|
|
private function shouldFallbackToMetadata(string $policyType, ?int $status): bool
|
|
{
|
|
// Only fallback on 5xx server errors
|
|
if ($status === null || $status < 500 || $status >= 600) {
|
|
return false;
|
|
}
|
|
|
|
// Enable fallback for policy types experiencing upstream Graph issues
|
|
$fallbackTypes = [
|
|
'mamAppConfiguration',
|
|
'managedDeviceAppConfiguration',
|
|
];
|
|
|
|
return in_array($policyType, $fallbackTypes, true);
|
|
}
|
|
|
|
/**
|
|
* Create a metadata-only snapshot from the Policy model when Graph is unavailable.
|
|
*
|
|
* @return array{payload:array,metadata:array,warnings:array}
|
|
*/
|
|
private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array
|
|
{
|
|
$odataType = match ($policy->policy_type) {
|
|
'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration',
|
|
'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration',
|
|
default => '#microsoft.graph.'.$policy->policy_type,
|
|
};
|
|
|
|
$payload = [
|
|
'id' => $policy->external_id,
|
|
'displayName' => $policy->display_name,
|
|
'@odata.type' => $odataType,
|
|
'createdDateTime' => $policy->created_at?->toIso8601String(),
|
|
'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(),
|
|
];
|
|
|
|
if ($policy->platform) {
|
|
$payload['platform'] = $policy->platform;
|
|
}
|
|
|
|
$metadata = [
|
|
'source' => 'metadata_only',
|
|
'original_failure' => $failureReason,
|
|
'original_status' => $status,
|
|
'warnings' => [
|
|
sprintf(
|
|
'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.',
|
|
$status ?? 'error'
|
|
),
|
|
],
|
|
];
|
|
|
|
return [
|
|
'payload' => $payload,
|
|
'metadata' => $metadata,
|
|
'warnings' => $metadata['warnings'],
|
|
];
|
|
}
|
|
}
|