expose enrollment config subtypes as their own policy types (limit/platform restrictions/notifications) with preview-only restore risk and proper Graph contracts classify enrollment configs by their @odata.type + deviceEnrollmentConfigurationType so sync only keeps ESP in windowsEnrollmentStatusPage and the rest stay in their own types, including new restore-normalizer UI blocks + warnings hydrate enrollment notifications: snapshot fetch now downloads each notification template + localized messages, normalized view surfaces template names/subjects/messages, and restore previews keep preview-only behavior tenant UI tweaks: Tenant list and detail actions moved into an action group; “Open in Entra” re-added in index, and detail now has “Deactivate” + tests covering the new menu layout and actions tests added/updated for sync, snapshots, restores, normalized settings, tenant UI, plus Pint/test suite run Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #31
843 lines
29 KiB
PHP
843 lines
29 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 ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') {
|
|
[$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
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];
|
|
}
|
|
|
|
/**
|
|
* Hydrate enrollment notifications with message template details.
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array
|
|
{
|
|
$existing = $payload['notificationTemplateSnapshots'] ?? null;
|
|
|
|
if (is_array($existing) && $existing !== []) {
|
|
$metadata['enrollment_notification_templates_hydration'] = 'embedded';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$templateRefs = $payload['notificationTemplates'] ?? null;
|
|
|
|
if (! is_array($templateRefs) || $templateRefs === []) {
|
|
$metadata['enrollment_notification_templates_hydration'] = 'none';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$options = [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
];
|
|
|
|
$snapshots = [];
|
|
$failures = 0;
|
|
|
|
foreach ($templateRefs as $templateRef) {
|
|
if (! is_string($templateRef) || $templateRef === '') {
|
|
continue;
|
|
}
|
|
|
|
[$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef);
|
|
|
|
if ($templateId === null) {
|
|
$failures++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId));
|
|
$templateResponse = $this->graphClient->request('GET', $templatePath, $options);
|
|
|
|
if ($templateResponse->failed() || ! is_array($templateResponse->data)) {
|
|
$failures++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$template = Arr::except($templateResponse->data, ['@odata.context']);
|
|
|
|
$messagesPath = sprintf(
|
|
'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages',
|
|
urlencode($templateId)
|
|
);
|
|
$messagesResponse = $this->graphClient->request('GET', $messagesPath, $options);
|
|
|
|
$messages = [];
|
|
|
|
if ($messagesResponse->failed()) {
|
|
$failures++;
|
|
} else {
|
|
$pageItems = $messagesResponse->data['value'] ?? [];
|
|
|
|
if (is_array($pageItems)) {
|
|
foreach ($pageItems as $message) {
|
|
if (is_array($message)) {
|
|
$messages[] = Arr::except($message, ['@odata.context']);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$snapshots[] = [
|
|
'channel' => $channel,
|
|
'template_id' => $templateId,
|
|
'template' => $template,
|
|
'localized_notification_messages' => $messages,
|
|
];
|
|
}
|
|
|
|
if ($snapshots === []) {
|
|
$metadata['enrollment_notification_templates_hydration'] = 'failed';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$payload['notificationTemplateSnapshots'] = $snapshots;
|
|
|
|
$metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* @return array{0:?string,1:?string}
|
|
*/
|
|
private function parseEnrollmentNotificationTemplateRef(string $templateRef): array
|
|
{
|
|
if (! str_contains($templateRef, '_')) {
|
|
return [null, $templateRef];
|
|
}
|
|
|
|
[$channel, $templateId] = explode('_', $templateRef, 2);
|
|
|
|
$channel = trim($channel);
|
|
$templateId = trim($templateId);
|
|
|
|
if ($templateId === '') {
|
|
return [$channel !== '' ? $channel : null, null];
|
|
}
|
|
|
|
return [$channel !== '' ? $channel : null, $templateId];
|
|
}
|
|
|
|
/**
|
|
* 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'],
|
|
];
|
|
}
|
|
}
|