TenantAtlas/app/Services/Intune/RestoreService.php
ahmido d120ed7c92 feat: endpoint security restore execution (023) (#25)
Added a resolver/validation flow that fetches endpoint security template definitions and enforces them before CREATE/PATCH so we don’t call Graph with invalid settings.
Hardened restore endpoint resolution (built-in fallback to deviceManagement/configurationPolicies, clearer error metadata, preview-only fallback when metadata is missing) and exposed Graph path/method in restore UI details.
Stripped read-only fields when PATCHing endpointSecurityIntent so the request no longer fails with “properties not patchable”.
Added regression tests covering endpoint security restore, intent sanitization, unknown type safety, Graph error metadata, and endpoint resolution behavior.
Testing

GraphClientEndpointResolutionTest.php
./vendor/bin/pint --dirty

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #25
2026-01-03 22:44:08 +00:00

2634 lines
93 KiB
PHP

<?php
namespace App\Services\Intune;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper;
use App\Services\Graph\GraphLogger;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Throwable;
class RestoreService
{
public function __construct(
private readonly GraphClientInterface $graphClient,
private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger,
private readonly VersionService $versionService,
private readonly SnapshotValidator $snapshotValidator,
private readonly GraphContractRegistry $contracts,
private readonly ConfigurationPolicyTemplateResolver $templateResolver,
private readonly AssignmentRestoreService $assignmentRestoreService,
private readonly FoundationMappingService $foundationMappingService,
) {}
/**
* Build a preview describing intended restore actions.
*
* @param array<int>|null $selectedItemIds
*/
public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array
{
$this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items);
$notificationTemplateIds = $foundationItems
->where('policy_type', 'notificationMessageTemplate')
->pluck('policy_identifier')
->filter()
->values()
->all();
$foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? [];
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) {
$existing = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $item->policy_identifier)
->where('policy_type', $item->policy_type)
->first();
$restoreMode = $this->resolveRestoreMode($item->policy_type);
$preview = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'policy_type' => $item->policy_type,
'platform' => $item->platform,
'action' => $existing ? 'update' : 'create',
'conflict' => false,
'restore_mode' => $restoreMode,
'validation_warning' => BackupItem::odataTypeWarning(
is_array($item->payload) ? $item->payload : [],
$item->policy_type,
$item->platform
) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
];
if ($item->policy_type === 'deviceCompliancePolicy') {
$preview = array_merge(
$preview,
$this->previewComplianceNotificationTemplates(
payload: is_array($item->payload) ? $item->payload : [],
availableTemplateIds: $notificationTemplateIds
)
);
}
return $preview;
})->all();
return array_merge($foundationPreview, $policyPreview);
}
/**
* Execute restore or dry-run for selected items.
*
* @param array<int>|null $selectedItemIds
*/
public function executeFromPolicyVersion(
Tenant $tenant,
PolicyVersion $version,
bool $dryRun = true,
?string $actorEmail = null,
?string $actorName = null,
array $groupMapping = [],
): RestoreRun {
if ($version->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Policy version does not belong to the provided tenant.');
}
$policy = $version->policy;
if (! $policy) {
throw new \RuntimeException('Policy version has no associated policy.');
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$version->version_number
),
'created_by' => $actorEmail,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $version->id,
'policy_version_number' => $version->version_number,
'policy_id' => $policy->id,
],
]);
$scopeTags = is_array($version->scope_tags) ? $version->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $version->id,
'policy_version_number' => $version->version_number,
'version_captured_at' => $version->captured_at?->toIso8601String(),
];
$versionMetadata = is_array($version->metadata) ? $version->metadata : [];
$snapshotSource = $versionMetadata['source'] ?? null;
if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') {
$backupItemMetadata['snapshot_source'] = $snapshotSource;
}
$snapshotWarnings = $versionMetadata['warnings'] ?? null;
if (is_array($snapshotWarnings) && $snapshotWarnings !== []) {
$backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== '')));
}
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
}
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_version_id' => $version->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
'payload' => $version->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $version->assignments,
]);
return $this->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: $dryRun,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $groupMapping,
);
}
public function executeForRun(
RestoreRun $restoreRun,
Tenant $tenant,
BackupSet $backupSet,
?string $actorEmail = null,
?string $actorName = null,
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
if ($restoreRun->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
}
if ($restoreRun->backup_set_id !== $backupSet->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
}
if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) {
throw new \RuntimeException('Restore run is already finished.');
}
$selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null;
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
return $this->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: (bool) $restoreRun->is_dry_run,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $restoreRun->group_mapping ?? [],
existingRun: $restoreRun,
);
}
public function execute(
Tenant $tenant,
BackupSet $backupSet,
?array $selectedItemIds = null,
bool $dryRun = true,
?string $actorEmail = null,
?string $actorName = null,
array $groupMapping = [],
?RestoreRun $existingRun = null,
): RestoreRun {
$this->assertActiveContext($tenant, $backupSet);
if ($selectedItemIds === []) {
$selectedItemIds = null;
}
$tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id;
$items = $this->loadItems($backupSet, $selectedItemIds);
[$foundationItems, $policyItems] = $this->splitItems($items);
$preview = $this->preview($tenant, $backupSet, $selectedItemIds);
$wizardMetadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()),
];
if ($existingRun !== null) {
if ($existingRun->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.');
}
if ($existingRun->backup_set_id !== $backupSet->id) {
throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.');
}
$metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []);
$existingRun->update([
'requested_by' => $existingRun->requested_by ?? $actorEmail,
'is_dry_run' => $dryRun,
'status' => 'running',
'requested_items' => $selectedItemIds,
'preview' => $preview,
'results' => null,
'failure_reason' => null,
'started_at' => $existingRun->started_at ?? CarbonImmutable::now(),
'completed_at' => null,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null),
]);
$restoreRun = $existingRun->refresh();
} else {
$restoreRun = RestoreRun::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'requested_by' => $actorEmail,
'is_dry_run' => $dryRun,
'status' => 'running',
'requested_items' => $selectedItemIds,
'preview' => $preview,
'started_at' => CarbonImmutable::now(),
'metadata' => $wizardMetadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
}
if ($groupMapping !== []) {
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.group_mapping.applied',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'mapped_groups' => count($groupMapping),
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: 'success'
);
}
$foundationOutcome = $this->foundationMappingService->map($tenant, $foundationItems, ! $dryRun);
$foundationEntries = $foundationOutcome['entries'] ?? [];
$foundationFailures = (int) ($foundationOutcome['failed'] ?? 0);
$foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0);
$foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries);
$scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? [];
$scopeTagNamesById = $this->buildScopeTagNameLookup($foundationEntries);
if (! $dryRun) {
$this->auditFoundationMapping(
tenant: $tenant,
restoreRun: $restoreRun,
entries: $foundationEntries,
actorEmail: $actorEmail,
actorName: $actorName
);
}
$results = $foundationEntries;
$hardFailures = $foundationFailures;
foreach ($policyItems as $item) {
$context = [
'tenant' => $tenantIdentifier,
'policy_type' => $item->policy_type,
'policy_id' => $item->policy_identifier,
'backup_item_id' => $item->id,
];
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode === 'preview-only') {
$results[] = $context + [
'status' => $dryRun ? 'dry_run' : 'skipped',
'reason' => 'preview_only',
'restore_mode' => $restoreMode,
];
continue;
}
$odataValidation = BackupItem::validateODataType(
is_array($item->payload) ? $item->payload : [],
$item->policy_type,
$item->platform
);
if (! $odataValidation['matches']) {
$results[] = $context + [
'status' => 'failed',
'reason' => BackupItem::odataTypeWarning(
is_array($item->payload) ? $item->payload : [],
$item->policy_type,
$item->platform
) ?? 'Snapshot type mismatch',
'code' => 'odata_mismatch',
];
$hardFailures++;
continue;
}
if ($dryRun) {
$results[] = $context + [
'status' => 'dry_run',
'restore_mode' => $restoreMode,
];
continue;
}
$this->graphLogger->logRequest('apply_policy', $context);
try {
$originalPayload = is_array($item->payload) ? $item->payload : [];
$originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping);
$complianceActionSummary = null;
$complianceActionOutcomes = null;
if ($item->policy_type === 'deviceCompliancePolicy') {
[$originalPayload, $complianceActionSummary, $complianceActionOutcomes] = $this->applyComplianceNotificationTemplateMapping(
payload: $originalPayload,
templateMapping: $foundationMappingByType['notificationMessageTemplate'] ?? []
);
}
$mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']);
// sanitize high-level fields according to contract
$payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload);
$payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping);
$graphOptions = [
'tenant' => $tenantIdentifier,
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $item->platform,
];
$updateMethod = $this->resolveUpdateMethod($item->policy_type);
$settingsApply = null;
$itemStatus = 'applied';
$settings = [];
$resultReason = null;
$createdPolicyId = null;
$createdPolicyMode = null;
$settingsApplyEligible = false;
if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) {
$policyType = $item->policy_type;
$settings = $this->extractSettingsCatalogSettings($originalPayload);
$policyPayload = $this->stripSettingsFromPayload($payload);
$response = $this->graphClient->applyPolicy(
$policyType,
$item->policy_identifier,
$policyPayload,
$graphOptions + ['method' => $updateMethod]
);
$settingsApplyEligible = $response->successful();
if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) {
if ($policyType === 'endpointSecurityPolicy') {
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
tenant: $tenant,
originalPayload: $originalPayload,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
);
}
$createOutcome = $this->createSettingsCatalogPolicy(
policyType: $policyType,
originalPayload: $originalPayload,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
fallbackName: $item->resolvedDisplayName(),
);
$response = $createOutcome['response'] ?? $response;
if ($createOutcome['success']) {
$createdPolicyId = $createOutcome['policy_id'];
$createdPolicyMode = $createOutcome['mode'] ?? null;
$mode = $createOutcome['mode'] ?? 'settings';
$itemStatus = $mode === 'settings' ? 'applied' : 'partial';
$resultReason = $mode === 'metadata_only'
? 'Policy missing; created metadata-only policy. Manual settings apply required.'
: 'Policy missing; created new policy with settings.';
if ($settings !== []) {
$settingsApply = $mode === 'metadata_only'
? [
'total' => count($settings),
'applied' => 0,
'failed' => 0,
'manual_required' => count($settings),
'issues' => [],
]
: [
'total' => count($settings),
'applied' => count($settings),
'failed' => 0,
'manual_required' => 0,
'issues' => [],
];
}
$settingsApplyEligible = false;
}
}
if ($settingsApplyEligible && $settings !== []) {
[$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings(
policyType: $policyType,
policyId: $item->policy_identifier,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
);
if ($itemStatus === 'manual_required' && $settingsApply !== null
&& $this->shouldAttemptSettingsCatalogCreate($settingsApply)) {
if ($policyType === 'endpointSecurityPolicy') {
$originalPayload = $this->prepareEndpointSecurityPolicyForCreate(
tenant: $tenant,
originalPayload: $originalPayload,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
);
}
$createOutcome = $this->createSettingsCatalogPolicy(
policyType: $policyType,
originalPayload: $originalPayload,
settings: $settings,
graphOptions: $graphOptions,
context: $context,
fallbackName: $item->resolvedDisplayName(),
);
if ($createOutcome['success']) {
$createdPolicyId = $createOutcome['policy_id'];
$createdPolicyMode = $createOutcome['mode'] ?? null;
$mode = $createOutcome['mode'] ?? 'settings';
// When settings are included in CREATE, mark as applied instead of partial
$itemStatus = $mode === 'settings' ? 'applied' : 'partial';
$resultReason = $mode === 'metadata_only'
? 'Settings endpoint unsupported; created metadata-only policy. Manual settings apply required.'
: 'Settings endpoint unsupported; created new policy with settings. Manual cleanup required.';
if ($settingsApply !== null && $createdPolicyId) {
$settingsApply['created_policy_id'] = $createdPolicyId;
$settingsApply['created_policy_mode'] = $mode;
// Update statistics when settings were included in CREATE
if ($mode === 'settings') {
$settingsApply['applied'] = $settingsApply['total'] ?? count($settings);
$settingsApply['manual_required'] = 0;
$settingsApply['issues'] = [];
}
}
} elseif ($settingsApply !== null && $createOutcome['response']) {
$settingsApply['issues'][] = [
'setting_id' => null,
'status' => 'manual_required',
'reason' => 'Fallback policy create failed',
'graph_error_message' => $createOutcome['response']->meta['error_message'] ?? null,
'graph_error_code' => $createOutcome['response']->meta['error_code'] ?? null,
'graph_request_id' => $createOutcome['response']->meta['request_id'] ?? null,
'graph_client_request_id' => $createOutcome['response']->meta['client_request_id'] ?? null,
];
}
}
}
} else {
if ($item->policy_type === 'appProtectionPolicy') {
$updatePath = $this->resolveAppProtectionPolicyUpdatePath(
policyId: $item->policy_identifier,
odataType: $this->resolvePayloadString($originalPayload, ['@odata.type']),
);
$response = $updatePath
? $this->graphClient->request(
$updateMethod,
$updatePath,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
)
: $this->graphClient->applyPolicy(
$item->policy_type,
$item->policy_identifier,
$payload,
$graphOptions + ['method' => $updateMethod]
);
} elseif ($item->policy_type === 'windowsUpdateRing') {
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
$castSegment = $odataType && str_starts_with($odataType, '#')
? ltrim($odataType, '#')
: 'microsoft.graph.windowsUpdateForBusinessConfiguration';
$updatePath = sprintf(
'deviceManagement/deviceConfigurations/%s/%s',
urlencode($item->policy_identifier),
$castSegment,
);
$response = $this->graphClient->request(
$updateMethod,
$updatePath,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
);
} else {
$response = $this->graphClient->applyPolicy(
$item->policy_type,
$item->policy_identifier,
$payload,
$graphOptions + ['method' => $updateMethod]
);
}
if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
$createOutcome = $this->createAutopilotDeploymentProfileIfMissing(
originalPayload: $originalPayload,
graphOptions: $graphOptions,
context: $context,
policyId: $item->policy_identifier,
);
if ($createOutcome['attempted']) {
$response = $createOutcome['response'] ?? $response;
if ($createOutcome['success']) {
$createdPolicyId = $createOutcome['policy_id'];
$createdPolicyMode = 'created';
$itemStatus = 'applied';
$resultReason = 'Policy missing; created new Autopilot profile.';
}
}
} elseif ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) {
$createOutcome = $this->createPolicyFromSnapshot(
policyType: $item->policy_type,
payload: $payload,
originalPayload: $originalPayload,
graphOptions: $graphOptions,
context: $context,
fallbackName: $item->resolvedDisplayName(),
);
if ($createOutcome['attempted']) {
$response = $createOutcome['response'] ?? $response;
if ($createOutcome['success']) {
$createdPolicyId = $createOutcome['policy_id'];
$createdPolicyMode = 'created';
$itemStatus = 'applied';
$resultReason = 'Policy missing; created new policy.';
}
}
}
}
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
$results[] = $context + [
'status' => 'failed',
'reason' => $mapped->getMessage(),
'code' => $mapped->status,
'graph_error_message' => $mapped->getMessage(),
'graph_error_code' => $mapped->status,
];
$hardFailures++;
continue;
}
$this->graphLogger->logResponse('apply_policy', $response, $context);
if ($response->failed()) {
$results[] = $context + [
'status' => 'failed',
'reason' => 'Graph apply failed',
'code' => $response->status,
'graph_error_message' => $response->meta['error_message'] ?? null,
'graph_error_code' => $response->meta['error_code'] ?? null,
'graph_request_id' => $response->meta['request_id'] ?? null,
'graph_client_request_id' => $response->meta['client_request_id'] ?? null,
'graph_method' => $response->meta['method'] ?? null,
'graph_path' => $response->meta['path'] ?? null,
];
$hardFailures++;
continue;
}
$assignmentOutcomes = null;
$assignmentSummary = null;
$restoredAssignments = null;
$definitionValueApply = null;
if (
! $dryRun
&& $item->policy_type === 'groupPolicyConfiguration'
&& is_array($originalPayload)
&& is_array($originalPayload['definitionValues'] ?? null)
&& $originalPayload['definitionValues'] !== []
) {
$definitionValueApply = $this->applyGroupPolicyDefinitionValues(
tenant: $tenant,
tenantIdentifier: $tenantIdentifier,
policyId: $createdPolicyId ?? $item->policy_identifier,
definitionValues: $originalPayload['definitionValues'],
graphOptions: $graphOptions,
context: $context,
);
$definitionSummary = $definitionValueApply['summary'] ?? null;
if (is_array($definitionSummary) && ($definitionSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Administrative Template settings restored with failures';
}
}
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
$assignmentOutcomes = $this->assignmentRestoreService->restore(
tenant: $tenant,
policyType: $item->policy_type,
policyId: $assignmentPolicyId,
assignments: $item->assignments,
groupMapping: $groupMapping,
foundationMapping: $foundationMappingByType,
restoreRun: $restoreRun,
actorEmail: $actorEmail,
actorName: $actorName,
policyOdataType: $this->resolvePayloadString($originalPayload, ['@odata.type']),
);
$assignmentSummary = $assignmentOutcomes['summary'] ?? null;
if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Assignments restored with failures';
}
}
if (is_array($assignmentOutcomes)) {
$restoredAssignments = collect($assignmentOutcomes['outcomes'] ?? [])
->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'success')
->pluck('assignment')
->filter()
->values()
->all();
if ($restoredAssignments === []) {
$restoredAssignments = null;
}
}
if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Compliance notification actions skipped';
}
if ($complianceActionSummary !== null) {
$this->auditComplianceActionMapping(
tenant: $tenant,
restoreRun: $restoreRun,
policyId: $item->policy_identifier,
policyType: $item->policy_type,
summary: $complianceActionSummary,
outcomes: $complianceActionOutcomes ?? [],
actorEmail: $actorEmail,
actorName: $actorName
);
}
$result = $context + [
'status' => $itemStatus,
'restore_mode' => $restoreMode,
];
if ($settingsApply !== null) {
$result['settings_apply'] = $settingsApply;
}
if ($createdPolicyId) {
$result['created_policy_id'] = $createdPolicyId;
}
if ($createdPolicyMode) {
$result['created_policy_mode'] = $createdPolicyMode;
}
if ($resultReason !== null) {
$result['reason'] = $resultReason;
} elseif ($itemStatus !== 'applied') {
$result['reason'] = 'Some settings require attention';
}
if ($assignmentOutcomes !== null) {
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
}
if ($assignmentSummary !== null) {
$result['assignment_summary'] = $assignmentSummary;
}
if (is_array($definitionValueApply)) {
$result['definition_value_outcomes'] = $definitionValueApply['outcomes'] ?? [];
$result['definition_value_summary'] = $definitionValueApply['summary'] ?? null;
}
if ($complianceActionSummary !== null) {
$result['compliance_action_summary'] = $complianceActionSummary;
}
if ($complianceActionOutcomes !== null) {
$result['compliance_action_outcomes'] = $complianceActionOutcomes;
}
$results[] = $result;
$appliedPolicyId = $item->policy_identifier;
$policy = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $appliedPolicyId)
->where('policy_type', $item->policy_type)
->first();
if ($policy && $itemStatus === 'applied') {
$scopeTagsForVersion = $this->buildScopeTagsForVersion(
scopeTagIds: $mappedScopeTagIds ?? null,
backupItemMetadata: $item->metadata ?? [],
scopeTagMapping: $scopeTagMapping,
scopeTagNamesById: $scopeTagNamesById,
);
$this->versionService->captureVersion(
policy: $policy,
payload: $item->payload,
createdBy: $actorEmail,
metadata: [
'source' => 'restore',
'restore_run_id' => $restoreRun->id,
'backup_item_id' => $item->id,
],
assignments: $item->assignments,
scopeTags: $scopeTagsForVersion,
);
}
}
$resultStatuses = collect($results)->pluck('status')->all();
$nonApplied = collect($resultStatuses)->filter(fn ($status) => is_string($status) && $status !== 'applied' && $status !== 'dry_run')->count();
$foundationNonApplied = collect($foundationEntries)->filter(function (array $entry): bool {
$decision = $entry['decision'] ?? null;
return in_array($decision, ['failed', 'skipped'], true);
})->count();
$nonApplied += $foundationNonApplied;
$totalCount = count($results);
$allHardFailed = $totalCount > 0 && $hardFailures === $totalCount;
$status = $dryRun
? 'previewed'
: (match (true) {
$allHardFailed => 'failed',
$nonApplied > 0 => 'partial',
default => 'completed',
});
$restoreRun->update([
'status' => $status,
'results' => $results,
'completed_at' => CarbonImmutable::now(),
'metadata' => array_merge($restoreRun->metadata ?? [], [
'failed' => $hardFailures,
'non_applied' => $nonApplied,
'total' => $totalCount,
'foundations_skipped' => $foundationSkipped,
]),
]);
$this->auditLogger->log(
tenant: $tenant,
action: $dryRun ? 'restore.previewed' : 'restore.executed',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'backup_set_id' => $backupSet->id,
'status' => $status,
'dry_run' => $dryRun,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: $status === 'completed' || $status === 'previewed' ? 'success' : 'partial'
);
return $restoreRun->refresh();
}
/**
* @param Collection<int, BackupItem> $items
* @return array{0: Collection<int, BackupItem>, 1: Collection<int, BackupItem>}
*/
private function splitItems(Collection $items): array
{
$foundationItems = $items->filter(fn (BackupItem $item) => $item->isFoundation())->values();
$policyItems = $items->reject(fn (BackupItem $item) => $item->isFoundation())->values();
return [$foundationItems, $policyItems];
}
/**
* @return array<string, mixed>
*/
private function resolveTypeMeta(string $policyType): array
{
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
foreach ($types as $typeConfig) {
if (($typeConfig['type'] ?? null) === $policyType) {
return $typeConfig;
}
}
return [];
}
private function resolveRestoreMode(string $policyType): string
{
$meta = $this->resolveTypeMeta($policyType);
if ($meta === []) {
return 'preview-only';
}
$restore = $meta['restore'] ?? 'enabled';
if (! is_string($restore) || $restore === '') {
return 'enabled';
}
return $restore;
}
private function resolveUpdateMethod(string $policyType): string
{
$contract = $this->contracts->get($policyType);
$method = strtoupper((string) ($contract['update_method'] ?? 'PATCH'));
return $method !== '' ? $method : 'PATCH';
}
private function resolveCreateMethod(string $policyType): ?string
{
$contract = $this->contracts->get($policyType);
$method = strtoupper((string) ($contract['create_method'] ?? 'POST'));
return $method !== '' ? $method : null;
}
private function shouldAttemptPolicyCreate(string $policyType, object $response): bool
{
if (! $this->isNotFoundResponse($response)) {
return false;
}
$resource = $this->contracts->resourcePath($policyType);
$method = $this->resolveCreateMethod($policyType);
return is_string($resource) && $resource !== '' && $method !== null;
}
private function isNotFoundResponse(object $response): bool
{
if (($response->status ?? null) === 404) {
return true;
}
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
if ($message !== '' && str_contains($message, 'resource not found for the segment')) {
return false;
}
if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) {
return true;
}
return $message !== '' && (str_contains($message, 'not found')
|| str_contains($message, 'resource not found')
|| str_contains($message, 'does not exist'));
}
/**
* @param array<int, array<string, mixed>> $entries
* @return array<string, array<string, string>>
*/
private function buildFoundationMappingByType(array $entries): array
{
$mapping = [];
foreach ($entries as $entry) {
if (! is_array($entry)) {
continue;
}
$type = $entry['type'] ?? null;
$sourceId = $entry['sourceId'] ?? null;
$targetId = $entry['targetId'] ?? null;
if (! is_string($type) || $type === '') {
continue;
}
if (! is_string($sourceId) || $sourceId === '') {
continue;
}
if (! is_string($targetId) || $targetId === '') {
continue;
}
$mapping[$type][$sourceId] = $targetId;
}
return $mapping;
}
/**
* @param array<string, mixed> $payload
* @param array<string, string> $scopeTagMapping
* @return array<string, mixed>
*/
private function applyScopeTagMapping(array $payload, array $scopeTagMapping): array
{
if ($scopeTagMapping === []) {
return $payload;
}
$roleScopeTagIds = $this->resolvePayloadArray($payload, ['roleScopeTagIds', 'RoleScopeTagIds']);
if ($roleScopeTagIds === null) {
return $payload;
}
$mapped = [];
foreach ($roleScopeTagIds as $id) {
if (is_string($id) || is_int($id)) {
$stringId = (string) $id;
$mapped[] = $scopeTagMapping[$stringId] ?? $stringId;
}
}
if ($mapped === []) {
return $payload;
}
$payload['roleScopeTagIds'] = array_values(array_unique($mapped));
unset($payload['RoleScopeTagIds']);
return $payload;
}
/**
* @param array<string, mixed> $payload
* @param array<int, mixed>|null $scopeTagIds
* @param array<string, string> $scopeTagMapping
* @return array<string, mixed>
*/
private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, array $scopeTagMapping): array
{
if ($scopeTagIds === null) {
return $payload;
}
$mapped = [];
foreach ($scopeTagIds as $id) {
if (! is_string($id) && ! is_int($id)) {
continue;
}
$stringId = (string) $id;
if ($stringId === '') {
continue;
}
$mapped[] = $scopeTagMapping[$stringId] ?? $stringId;
}
if ($mapped === []) {
return $payload;
}
$payload['roleScopeTagIds'] = array_values(array_unique($mapped));
unset($payload['RoleScopeTagIds']);
return $payload;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $availableTemplateIds
* @return array<string, mixed>
*/
private function previewComplianceNotificationTemplates(array $payload, array $availableTemplateIds): array
{
$templateIds = $this->collectComplianceNotificationTemplateIds($payload);
if ($templateIds === []) {
return [];
}
$available = array_values(array_unique($availableTemplateIds));
$missing = array_values(array_diff($templateIds, $available));
$summary = [
'total' => count($templateIds),
'missing' => count($missing),
];
$warning = null;
if ($missing !== []) {
$warning = sprintf('Missing %d notification template(s); notification actions may be skipped.', count($missing));
}
return array_filter([
'compliance_action_summary' => $summary,
'compliance_action_warning' => $warning,
'compliance_action_missing_templates' => $missing !== [] ? $missing : null,
], static fn ($value) => $value !== null);
}
/**
* @param array<string, mixed> $payload
* @param array<string, string> $templateMapping
* @return array{0: array<string, mixed>, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array<int, array<string, mixed>>}
*/
private function applyComplianceNotificationTemplateMapping(array $payload, array $templateMapping): array
{
$scheduled = $payload['scheduledActionsForRule'] ?? null;
if (! is_array($scheduled)) {
return [$payload, null, null];
}
$rules = [];
$total = 0;
$mapped = 0;
$skipped = 0;
$outcomes = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
$rules[] = $rule;
continue;
}
$ruleName = $rule['ruleName'] ?? null;
$updatedConfigs = [];
foreach ($configs as $config) {
if (! is_array($config)) {
$updatedConfigs[] = $config;
continue;
}
$actionType = $config['actionType'] ?? null;
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($actionType !== 'notification' || $templateKey === null) {
$updatedConfigs[] = $config;
continue;
}
$templateId = $config[$templateKey] ?? null;
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
$updatedConfigs[] = $config;
continue;
}
$total++;
if ($templateMapping === []) {
$outcomes[] = [
'status' => 'skipped',
'template_id' => $templateId,
'rule_name' => $ruleName,
'reason' => 'Notification template mapping unavailable.',
];
$skipped++;
continue;
}
$mappedTemplateId = $templateMapping[$templateId] ?? null;
if (! is_string($mappedTemplateId) || $mappedTemplateId === '') {
$outcomes[] = [
'status' => 'skipped',
'template_id' => $templateId,
'rule_name' => $ruleName,
'reason' => 'Notification template mapping missing for template ID.',
];
$skipped++;
continue;
}
$config[$templateKey] = $mappedTemplateId;
$updatedConfigs[] = $config;
$mapped++;
$outcomes[] = [
'status' => 'mapped',
'template_id' => $templateId,
'mapped_template_id' => $mappedTemplateId,
'rule_name' => $ruleName,
];
}
if ($updatedConfigs === []) {
continue;
}
$rule['scheduledActionConfigurations'] = array_values($updatedConfigs);
$rules[] = $rule;
}
if ($rules !== []) {
$payload['scheduledActionsForRule'] = array_values($rules);
} else {
unset($payload['scheduledActionsForRule']);
}
if ($total === 0) {
return [$payload, null, null];
}
return [$payload, ['total' => $total, 'mapped' => $mapped, 'skipped' => $skipped], $outcomes];
}
/**
* @param array<string, mixed> $payload
* @return array<int, string>
*/
private function collectComplianceNotificationTemplateIds(array $payload): array
{
$scheduled = $payload['scheduledActionsForRule'] ?? null;
if (! is_array($scheduled)) {
return [];
}
$ids = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
continue;
}
foreach ($configs as $config) {
if (! is_array($config)) {
continue;
}
if (($config['actionType'] ?? null) !== 'notification') {
continue;
}
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($templateKey === null) {
continue;
}
$templateId = $config[$templateKey] ?? null;
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
continue;
}
$ids[] = $templateId;
}
}
return array_values(array_unique($ids));
}
private function resolveNotificationTemplateKey(array $config): ?string
{
if (array_key_exists('notificationTemplateId', $config)) {
return 'notificationTemplateId';
}
if (array_key_exists('notificationMessageTemplateId', $config)) {
return 'notificationMessageTemplateId';
}
return null;
}
private function isEmptyGuid(string $value): bool
{
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
}
/**
* @param array<int, array<string, mixed>> $entries
*/
private function auditFoundationMapping(
Tenant $tenant,
RestoreRun $restoreRun,
array $entries,
?string $actorEmail,
?string $actorName
): void {
foreach ($entries as $entry) {
if (! is_array($entry)) {
continue;
}
$decision = $entry['decision'] ?? 'mapped_existing';
$action = match ($decision) {
'created_copy' => 'restore.foundation.created_copy',
'created' => 'restore.foundation.created',
'failed' => 'restore.foundation.failed',
'skipped' => 'restore.foundation.skipped',
default => 'restore.foundation.mapped',
};
$status = match ($decision) {
'failed' => 'failed',
'skipped' => 'warning',
default => 'success',
};
$this->auditLogger->log(
tenant: $tenant,
action: $action,
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'type' => $entry['type'] ?? null,
'source_id' => $entry['sourceId'] ?? null,
'source_name' => $entry['sourceName'] ?? null,
'target_id' => $entry['targetId'] ?? null,
'target_name' => $entry['targetName'] ?? null,
'decision' => $decision,
'reason' => $entry['reason'] ?? null,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: $status
);
}
}
/**
* @param array{total:int,mapped:int,skipped:int} $summary
* @param array<int, array<string, mixed>> $outcomes
*/
private function auditComplianceActionMapping(
Tenant $tenant,
RestoreRun $restoreRun,
string $policyId,
string $policyType,
array $summary,
array $outcomes,
?string $actorEmail,
?string $actorName
): void {
$skipped = (int) ($summary['skipped'] ?? 0);
$status = $skipped > 0 ? 'warning' : 'success';
$skippedTemplates = collect($outcomes)
->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'skipped')
->pluck('template_id')
->filter()
->values()
->all();
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.compliance.actions.mapped',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'policy_id' => $policyId,
'policy_type' => $policyType,
'total' => (int) ($summary['total'] ?? 0),
'mapped' => (int) ($summary['mapped'] ?? 0),
'skipped' => $skipped,
'skipped_template_ids' => $skippedTemplates,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: $status
);
}
/**
* @param array<int>|null $selectedItemIds
*/
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds = null): Collection
{
$query = $backupSet->items()->getQuery();
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
return $query->orderBy('id')->get();
}
/**
* Strip read-only/metadata fields before sending payload back to Graph.
*/
private function sanitizePayload(array $payload): array
{
$readOnlyKeys = [
'@odata.context',
'id',
'createdDateTime',
'lastModifiedDateTime',
'version',
'supportsScopeTags',
'roleScopeTagIds',
];
$clean = [];
foreach ($payload as $key => $value) {
// Drop read-only/meta keys except @odata.type which we keep for type hinting
if (in_array($key, $readOnlyKeys, true)) {
continue;
}
// Keep @odata.type for Graph type resolution
if ($key === '@odata.type') {
$clean[$key] = $value;
continue;
}
if (is_array($value)) {
$clean[$key] = $this->sanitizePayload($value);
continue;
}
$clean[$key] = $value;
}
return $clean;
}
/**
* @return array<int, mixed>
*/
private function extractSettingsCatalogSettings(array $payload): array
{
foreach ($payload as $key => $value) {
if (strtolower((string) $key) !== 'settings') {
continue;
}
return is_array($value) ? $value : [];
}
return [];
}
private function stripSettingsFromPayload(array $payload): array
{
foreach (array_keys($payload) as $key) {
if (strtolower((string) $key) === 'settings') {
unset($payload[$key]);
}
}
return $payload;
}
private function resolveSettingsCatalogSettingId(array $setting): ?string
{
foreach ($setting as $key => $value) {
if (strtolower((string) $key) !== 'id') {
continue;
}
if (is_string($value) || is_int($value)) {
return (string) $value;
}
return null;
}
return null;
}
/**
* @param array<int, mixed> $settings
* @param array<string, mixed> $graphOptions
* @param array<string, mixed> $context
* @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array<int,array<string,mixed>>}, 1: string}
*/
private function applySettingsCatalogPolicySettings(
string $policyType,
string $policyId,
array $settings,
array $graphOptions,
array $context,
): array {
$method = $this->contracts->settingsWriteMethod($policyType);
$path = $this->contracts->settingsWritePath($policyType, $policyId);
$bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType));
$fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType);
$buildIssues = function (string $reason) use ($settings): array {
$issues = [];
foreach ($settings as $setting) {
if (! is_array($setting)) {
continue;
}
$issues[] = [
'setting_id' => $this->resolveSettingsCatalogSettingId($setting),
'status' => 'manual_required',
'reason' => $reason,
];
}
return $issues;
};
if (! $method || ! $path) {
return [
[
'total' => count($settings),
'applied' => 0,
'failed' => 0,
'manual_required' => count($settings),
'issues' => $buildIssues('Settings write contract is not configured (cannot apply automatically).'),
],
'manual_required',
];
}
$sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
if (! is_array($sanitized) || $sanitized === []) {
return [
[
'total' => count($settings),
'applied' => 0,
'failed' => 0,
'manual_required' => count($settings),
'issues' => $buildIssues('Settings payload could not be sanitized (empty payload).'),
],
'manual_required',
];
}
$buildPayload = function (string $shape) use ($sanitized): array {
return match ($shape) {
'wrapped' => ['settings' => $sanitized],
default => $sanitized,
};
};
$payload = $buildPayload($bodyShape);
$this->graphLogger->logRequest('apply_settings_bulk', $context + [
'endpoint' => $path,
'method' => $method,
'settings_count' => count($sanitized),
'body_shape' => $bodyShape,
]);
$response = $this->graphClient->request($method, $path, ['json' => $payload] + Arr::except($graphOptions, ['platform']));
$this->graphLogger->logResponse('apply_settings_bulk', $response, $context + [
'endpoint' => $path,
'method' => $method,
'settings_count' => count($sanitized),
'body_shape' => $bodyShape,
]);
if ($response->failed() && is_string($fallbackShape) && strtolower($fallbackShape) !== $bodyShape) {
$fallbackShape = strtolower($fallbackShape);
if ($this->shouldRetrySettingsBulkApply($response->meta['error_message'] ?? null)) {
$fallbackPayload = $buildPayload($fallbackShape);
$this->graphLogger->logRequest('apply_settings_bulk_retry', $context + [
'endpoint' => $path,
'method' => $method,
'settings_count' => count($sanitized),
'body_shape' => $fallbackShape,
]);
$response = $this->graphClient->request($method, $path, ['json' => $fallbackPayload] + Arr::except($graphOptions, ['platform']));
$this->graphLogger->logResponse('apply_settings_bulk_retry', $response, $context + [
'endpoint' => $path,
'method' => $method,
'settings_count' => count($sanitized),
'body_shape' => $fallbackShape,
]);
}
}
if ($response->successful()) {
return [
[
'total' => count($settings),
'applied' => count($settings),
'failed' => 0,
'manual_required' => 0,
'issues' => [],
],
'applied',
];
}
return [
[
'total' => count($settings),
'applied' => 0,
'failed' => 0,
'manual_required' => count($settings),
'issues' => [[
'setting_id' => null,
'status' => 'manual_required',
'reason' => 'Graph bulk apply failed',
'http_status' => $response->status,
'graph_error_message' => $response->meta['error_message'] ?? null,
'graph_error_code' => $response->meta['error_code'] ?? null,
'graph_request_id' => $response->meta['request_id'] ?? null,
'graph_client_request_id' => $response->meta['client_request_id'] ?? null,
]],
],
'manual_required',
];
}
private function shouldRetrySettingsBulkApply(?string $errorMessage): bool
{
if (! is_string($errorMessage) || $errorMessage === '') {
return false;
}
$message = strtolower($errorMessage);
return str_contains($message, 'empty payload')
|| str_contains($message, 'json content expected')
|| str_contains($message, 'request body');
}
private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool
{
$issues = $settingsApply['issues'] ?? [];
foreach ($issues as $issue) {
$message = strtolower((string) ($issue['graph_error_message'] ?? $issue['reason'] ?? ''));
if ($message === '') {
continue;
}
if (str_contains($message, 'no odata route exists') || str_contains($message, 'no method match route template')) {
return true;
}
}
return false;
}
/**
* @return array{success:bool,policy_id:?string,response:?object,mode:string}
*/
private function createSettingsCatalogPolicy(
string $policyType,
array $originalPayload,
array $settings,
array $graphOptions,
array $context,
string $fallbackName,
): array {
$resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies';
$sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings);
if ($sanitizedSettings === []) {
return [
'success' => false,
'policy_id' => null,
'response' => null,
'mode' => 'failed',
];
}
$payload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName, true);
$this->graphLogger->logRequest('create_settings_catalog_policy', $context + [
'endpoint' => $resource,
'method' => 'POST',
'settings_count' => count($sanitizedSettings),
]);
$response = $this->graphClient->request('POST', $resource, ['json' => $payload] + Arr::except($graphOptions, ['platform']));
$this->graphLogger->logResponse('create_settings_catalog_policy', $response, $context + [
'endpoint' => $resource,
'method' => 'POST',
'settings_count' => count($sanitizedSettings),
]);
$policyId = $this->extractCreatedPolicyId($response);
$mode = 'settings';
if ($response->failed() && $this->shouldRetrySettingsCatalogCreateWithoutSettings($response)) {
$fallbackPayload = $this->buildSettingsCatalogCreatePayload($originalPayload, $sanitizedSettings, $fallbackName, false);
$this->graphLogger->logRequest('create_settings_catalog_policy_fallback', $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$response = $this->graphClient->request('POST', $resource, ['json' => $fallbackPayload] + Arr::except($graphOptions, ['platform']));
$this->graphLogger->logResponse('create_settings_catalog_policy_fallback', $response, $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$policyId = $this->extractCreatedPolicyId($response);
$mode = 'metadata_only';
}
return [
'success' => $response->successful(),
'policy_id' => $policyId,
'response' => $response,
'mode' => $mode,
];
}
/**
* @param array<string, mixed> $originalPayload
* @param array<int, mixed> $settings
* @param array<string, mixed> $graphOptions
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function prepareEndpointSecurityPolicyForCreate(
Tenant $tenant,
array $originalPayload,
array $settings,
array $graphOptions,
array $context,
): array {
$templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']);
if (! is_array($templateReference)) {
throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.');
}
$templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions);
if (! ($templateOutcome['success'] ?? false)) {
$reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.';
throw new \RuntimeException($reason);
}
$resolvedTemplateId = $templateOutcome['template_id'] ?? null;
$resolvedReference = $templateOutcome['template_reference'] ?? $templateReference;
if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') {
throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).');
}
if (is_array($resolvedReference) && $resolvedReference !== []) {
$originalPayload['templateReference'] = $resolvedReference;
}
if ($settings === []) {
return $originalPayload;
}
$definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions);
if (! ($definitions['success'] ?? false)) {
return $originalPayload;
}
$templateDefinitionIds = $definitions['definition_ids'] ?? [];
if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) {
return $originalPayload;
}
$policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings);
$missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds));
if ($missing === []) {
return $originalPayload;
}
$sample = implode(', ', array_slice($missing, 0, 5));
$suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : '';
throw new \RuntimeException(sprintf(
'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s',
$resolvedTemplateId,
$sample,
$suffix,
));
}
/**
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
*/
private function createPolicyFromSnapshot(
string $policyType,
array $payload,
array $originalPayload,
array $graphOptions,
array $context,
string $fallbackName,
): array {
$resource = $this->contracts->resourcePath($policyType);
$method = $this->resolveCreateMethod($policyType);
if ($policyType === 'appProtectionPolicy') {
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
$derivedResource = $this->resolveAppProtectionPolicyResource($odataType);
if ($derivedResource !== null) {
$resource = $derivedResource;
}
}
if (! is_string($resource) || $resource === '' || $method === null) {
return [
'attempted' => false,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$createPayload = Arr::except($payload, ['assignments']);
$createPayload = $this->applyOdataTypeForCreate($policyType, $createPayload, $originalPayload);
$createPayload = $this->applyRestoredNameToPayload($createPayload, $originalPayload, $fallbackName);
if ($createPayload === []) {
return [
'attempted' => true,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$this->graphLogger->logRequest('create_policy', $context + [
'endpoint' => $resource,
'method' => $method,
'policy_type' => $policyType,
]);
$response = $this->graphClient->request(
$method,
$resource,
['json' => $createPayload] + Arr::except($graphOptions, ['platform'])
);
$this->graphLogger->logResponse('create_policy', $response, $context + [
'endpoint' => $resource,
'method' => $method,
'policy_type' => $policyType,
]);
$policyId = $this->extractCreatedPolicyId($response);
return [
'attempted' => true,
'success' => $response->successful(),
'policy_id' => $policyId,
'response' => $response,
];
}
/**
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
*/
private function createAutopilotDeploymentProfileIfMissing(
array $originalPayload,
array $graphOptions,
array $context,
string $policyId,
): array {
if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) {
return [
'attempted' => false,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile')
?? 'deviceManagement/windowsAutopilotDeploymentProfiles';
$payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload);
$payload['displayName'] = $this->prefixRestoredName(
$this->resolvePayloadString($payload, ['displayName', 'name']),
$policyId
);
unset($payload['name']);
if ($payload === []) {
return [
'attempted' => true,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$this->graphLogger->logRequest('create_autopilot_profile', $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$response = $this->graphClient->request(
'POST',
$resource,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
);
$this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$policyId = $this->extractCreatedPolicyId($response);
return [
'attempted' => true,
'success' => $response->successful(),
'policy_id' => $policyId,
'response' => $response,
];
}
private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool
{
$response = $this->graphClient->getPolicy(
'windowsAutopilotDeploymentProfile',
$policyId,
$graphOptions
);
if ($response->successful()) {
return false;
}
if ($response->status === 404) {
return true;
}
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
if (str_contains($code, 'notfound') || str_contains($code, 'resource')) {
return true;
}
return str_contains($message, 'not found')
|| str_contains($message, 'resource not found')
|| str_contains($message, 'does not exist');
}
private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool
{
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
if ($code === 'notsupported' || str_contains($code, 'notsupported')) {
return true;
}
return str_contains($message, 'not supported');
}
private function extractCreatedPolicyId(object $response): ?string
{
if ($response->successful() && isset($response->data['id']) && is_string($response->data['id'])) {
return $response->data['id'];
}
return null;
}
/**
* @param array<int, mixed> $settings
* @return array<string, mixed>
*/
private function buildSettingsCatalogCreatePayload(
array $originalPayload,
array $settings,
string $fallbackName,
bool $includeSettings,
): array {
$payload = [];
$name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']);
$payload['name'] = $this->prefixRestoredName($name, $fallbackName);
$description = $this->resolvePayloadString($originalPayload, ['description', 'Description']);
if ($description !== null) {
$payload['description'] = $description;
}
// Platforms and technologies must be singular strings for CREATE (not arrays)
// Graph API inconsistency: GET returns arrays, but POST expects strings
$platforms = $this->resolvePayloadArray($originalPayload, ['platforms', 'Platforms']);
if ($platforms !== null && $platforms !== []) {
$payload['platforms'] = is_array($platforms) ? $platforms[0] : $platforms;
} elseif ($platforms === null) {
// Fallback: extract from policy_type or default to windows10
$payload['platforms'] = 'windows10';
}
$technologies = $this->resolvePayloadArray($originalPayload, ['technologies', 'Technologies']);
if ($technologies !== null && $technologies !== []) {
$payload['technologies'] = is_array($technologies) ? $technologies[0] : $technologies;
} elseif ($technologies === null) {
// Default to mdm if not present
$payload['technologies'] = 'mdm';
}
$roleScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']);
if ($roleScopeTagIds !== null) {
$payload['roleScopeTagIds'] = array_values($roleScopeTagIds);
}
$templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']);
if ($templateReference !== null) {
$payload['templateReference'] = $this->stripOdataAndReadOnly($templateReference);
}
if ($includeSettings && $settings !== []) {
$payload['settings'] = $settings;
}
return $payload;
}
private function prefixRestoredName(?string $name, string $fallback): string
{
$prefix = 'Restored_';
$base = trim((string) ($name ?? $fallback));
if ($base === '') {
$base = $fallback;
}
$normalized = strtolower($base);
if (str_starts_with($normalized, 'restored_') || str_starts_with($normalized, 'restored ')) {
return $base;
}
return $prefix.$base;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $originalPayload
* @return array<string, mixed>
*/
private function applyRestoredNameToPayload(array $payload, array $originalPayload, string $fallbackName): array
{
$displayName = $this->resolvePayloadString($payload, ['displayName']);
$name = $this->resolvePayloadString($payload, ['name']);
$originalDisplayName = $this->resolvePayloadString($originalPayload, ['displayName']);
$originalName = $this->resolvePayloadString($originalPayload, ['name']);
$baseName = $displayName ?? $originalDisplayName ?? $name ?? $originalName ?? $fallbackName;
$restoredName = $this->prefixRestoredName($baseName, $fallbackName);
if (array_key_exists('displayName', $payload) || $originalDisplayName !== null || $displayName !== null) {
$payload['displayName'] = $restoredName;
return $payload;
}
if (array_key_exists('name', $payload) || $originalName !== null || $name !== null) {
$payload['name'] = $restoredName;
return $payload;
}
$payload['displayName'] = $restoredName;
return $payload;
}
/**
* @param array<string, mixed> $payload
* @param array<string, mixed> $originalPayload
* @return array<string, mixed>
*/
private function applyOdataTypeForCreate(string $policyType, array $payload, array $originalPayload): array
{
if (array_key_exists('@odata.type', $payload)) {
return $payload;
}
$odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']);
if ($odataType === null) {
return $payload;
}
if (! $this->contracts->matchesTypeFamily($policyType, $odataType)) {
return $payload;
}
$payload['@odata.type'] = $odataType;
return $payload;
}
private function resolveAppProtectionPolicyUpdatePath(string $policyId, ?string $odataType): ?string
{
$resource = $this->resolveAppProtectionPolicyResource($odataType);
if ($resource === null) {
return null;
}
return sprintf('%s/%s', rtrim($resource, '/'), urlencode($policyId));
}
private function resolveAppProtectionPolicyResource(?string $odataType): ?string
{
$entitySet = $this->resolveAppProtectionEntitySet($odataType);
if ($entitySet === null) {
return null;
}
return "deviceAppManagement/{$entitySet}";
}
private function resolveAppProtectionEntitySet(?string $odataType): ?string
{
if (! is_string($odataType) || $odataType === '') {
return null;
}
return match (strtolower($odataType)) {
'#microsoft.graph.androidmanagedappprotection' => 'androidManagedAppProtections',
'#microsoft.graph.iosmanagedappprotection' => 'iosManagedAppProtections',
'#microsoft.graph.windowsinformationprotectionpolicy' => 'windowsInformationProtectionPolicies',
'#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'mdmWindowsInformationProtectionPolicies',
'#microsoft.graph.targetedmanagedappprotection' => 'targetedManagedAppProtections',
default => null,
};
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
*/
private function resolvePayloadString(array $payload, array $keys): ?string
{
$value = $this->resolvePayloadValue($payload, $keys);
if (! is_string($value) || trim($value) === '') {
return null;
}
return $value;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
* @return array<int, mixed>|null
*/
private function resolvePayloadArray(array $payload, array $keys): ?array
{
$value = $this->resolvePayloadValue($payload, $keys);
if (! is_array($value) || $value === []) {
return null;
}
return $value;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys
*/
private function resolvePayloadValue(array $payload, array $keys): mixed
{
$normalized = array_map('strtolower', $keys);
foreach ($payload as $key => $value) {
if (in_array(strtolower((string) $key), $normalized, true)) {
return $value;
}
}
return null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function stripOdataAndReadOnly(array $payload): array
{
$clean = [];
$readOnlyKeys = ['id', 'createddatetime', 'lastmodifieddatetime', 'version'];
foreach ($payload as $key => $value) {
$normalizedKey = strtolower((string) $key);
if (str_starts_with($normalizedKey, '@odata')) {
continue;
}
if (in_array($normalizedKey, $readOnlyKeys, true)) {
continue;
}
if (is_array($value)) {
if (array_is_list($value)) {
$items = array_map(function ($item) {
if (is_array($item)) {
return $this->stripOdataAndReadOnly($item);
}
return $item;
}, $value);
$clean[$key] = array_values(array_filter($items, static fn ($item) => $item !== []));
continue;
}
$clean[$key] = $this->stripOdataAndReadOnly($value);
continue;
}
$clean[$key] = $value;
}
return $clean;
}
/**
* Administrative Templates (groupPolicyConfiguration) restore: wipe existing definitionValues and recreate from snapshot.
*
* @param array<int, mixed> $definitionValues
* @param array<string, mixed> $graphOptions
* @param array<string, mixed> $context
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
*/
private function applyGroupPolicyDefinitionValues(
Tenant $tenant,
string $tenantIdentifier,
string $policyId,
array $definitionValues,
array $graphOptions,
array $context,
): array {
$outcomes = [];
$summary = ['success' => 0, 'failed' => 0, 'skipped' => 0];
$listPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues";
$createPath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues";
$this->graphLogger->logRequest('restore_group_policy_definition_values_list', $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$existingResponse = $this->graphClient->request('GET', $listPath, $graphOptions);
$this->graphLogger->logResponse('restore_group_policy_definition_values_list', $existingResponse, $context + [
'method' => 'GET',
'endpoint' => $listPath,
]);
$existing = $existingResponse->data['value'] ?? [];
foreach ($existing as $existingValue) {
$existingId = is_array($existingValue) ? ($existingValue['id'] ?? null) : null;
if (! is_string($existingId) || $existingId === '') {
continue;
}
$deletePath = "/deviceManagement/groupPolicyConfigurations/{$policyId}/definitionValues/{$existingId}";
$this->graphLogger->logRequest('restore_group_policy_definition_values_delete', $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'definition_value_id' => $existingId,
]);
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
$this->graphLogger->logResponse('restore_group_policy_definition_values_delete', $deleteResponse, $context + [
'method' => 'DELETE',
'endpoint' => $deletePath,
'definition_value_id' => $existingId,
]);
}
foreach ($definitionValues as $definitionValue) {
if (! is_array($definitionValue)) {
continue;
}
$displayName = $definitionValue['#Definition_displayName'] ?? null;
$definitionId = $definitionValue['#Definition_Id'] ?? null;
$sanitized = $this->sanitizeGroupPolicyDefinitionValue($definitionValue);
if (! isset($sanitized['definition@odata.bind'])) {
$outcomes[] = [
'status' => 'skipped',
'definition_id' => $definitionId,
'definition' => $displayName,
'reason' => 'Missing definition@odata.bind',
];
$summary['skipped']++;
continue;
}
$this->graphLogger->logRequest('restore_group_policy_definition_values_create', $context + [
'method' => 'POST',
'endpoint' => $createPath,
'definition_id' => $definitionId,
'definition' => $displayName,
]);
$createResponse = $this->graphClient->request('POST', $createPath, [
'json' => $sanitized,
] + $graphOptions);
$this->graphLogger->logResponse('restore_group_policy_definition_values_create', $createResponse, $context + [
'method' => 'POST',
'endpoint' => $createPath,
'definition_id' => $definitionId,
'definition' => $displayName,
]);
if ($createResponse->successful()) {
$outcomes[] = [
'status' => 'success',
'definition_id' => $definitionId,
'definition' => $displayName,
];
$summary['success']++;
} else {
$outcomes[] = array_filter([
'status' => 'failed',
'definition_id' => $definitionId,
'definition' => $displayName,
'reason' => $createResponse->meta['error_message'] ?? 'Graph create failed',
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
'graph_request_id' => $createResponse->meta['request_id'] ?? null,
'graph_client_request_id' => $createResponse->meta['client_request_id'] ?? null,
], static fn ($value) => $value !== null);
$summary['failed']++;
}
usleep(100000);
}
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.group_policy_definition_values.applied',
context: [
'metadata' => [
'tenant' => $tenantIdentifier,
'policy_id' => $policyId,
'summary' => $summary,
],
],
status: ($summary['failed'] ?? 0) > 0 ? 'warning' : 'success',
resourceType: 'policy',
resourceId: $policyId
);
return [
'outcomes' => $outcomes,
'summary' => $summary,
];
}
/**
* @param array<string, mixed> $definitionValue
* @return array<string, mixed>
*/
private function sanitizeGroupPolicyDefinitionValue(array $definitionValue): array
{
$clean = [];
foreach ($definitionValue as $key => $value) {
if (is_string($key) && str_starts_with($key, '#')) {
continue;
}
if ($key === 'id') {
continue;
}
if ($key === 'presentationValues' && is_array($value)) {
$cleanPresentationValues = [];
foreach ($value as $presentationValue) {
if (! is_array($presentationValue)) {
continue;
}
$presentationClean = [];
foreach ($presentationValue as $pKey => $pValue) {
if (is_string($pKey) && str_starts_with($pKey, '#')) {
continue;
}
if (in_array($pKey, ['id', 'createdDateTime', 'lastModifiedDateTime', 'presentation'], true)) {
continue;
}
$presentationClean[$pKey] = $pValue;
}
if ($presentationClean !== []) {
$cleanPresentationValues[] = $presentationClean;
}
}
if ($cleanPresentationValues !== []) {
$clean['presentationValues'] = $cleanPresentationValues;
}
continue;
}
$clean[$key] = $value;
}
return $clean;
}
/**
* @param array<int, array<string, mixed>> $foundationEntries
* @return array<string, string>
*/
private function buildScopeTagNameLookup(array $foundationEntries): array
{
$names = [];
foreach ($foundationEntries as $entry) {
if (! is_array($entry)) {
continue;
}
if (($entry['type'] ?? null) !== 'roleScopeTag') {
continue;
}
$targetId = $entry['targetId'] ?? null;
$targetName = $entry['targetName'] ?? null;
if (! is_string($targetId) || $targetId === '') {
continue;
}
if (! is_string($targetName) || $targetName === '') {
continue;
}
$names[$targetId] = $targetName;
}
return $names;
}
/**
* @param array<int, mixed>|null $scopeTagIds
* @param array<string, mixed> $backupItemMetadata
* @param array<string, string> $scopeTagMapping
* @param array<string, string> $scopeTagNamesById
* @return array{ids: array<int, string>, names: array<int, string>}|null
*/
private function buildScopeTagsForVersion(
?array $scopeTagIds,
array $backupItemMetadata,
array $scopeTagMapping,
array $scopeTagNamesById,
): ?array {
if ($scopeTagIds === null) {
return null;
}
$ids = [];
foreach ($scopeTagIds as $id) {
if (! is_string($id) && ! is_int($id)) {
continue;
}
$id = (string) $id;
if ($id === '') {
continue;
}
$ids[] = $id;
}
$ids = array_values(array_unique($ids));
if ($ids === []) {
return null;
}
$namesById = $scopeTagNamesById;
$metaScopeTagIds = $backupItemMetadata['scope_tag_ids'] ?? null;
$metaScopeTagNames = $backupItemMetadata['scope_tag_names'] ?? null;
if (is_array($metaScopeTagIds) && is_array($metaScopeTagNames)) {
foreach ($metaScopeTagIds as $index => $sourceId) {
if (! is_string($sourceId) && ! is_int($sourceId)) {
continue;
}
$sourceId = (string) $sourceId;
if ($sourceId === '') {
continue;
}
$name = $metaScopeTagNames[$index] ?? null;
if (! is_string($name) || $name === '') {
continue;
}
$targetId = $scopeTagMapping[$sourceId] ?? $sourceId;
if ($targetId !== '' && ! array_key_exists($targetId, $namesById)) {
$namesById[$targetId] = $name;
}
}
}
$names = [];
foreach ($ids as $id) {
if ($id === '0') {
$names[] = 'Default';
continue;
}
$names[] = $namesById[$id] ?? "Unknown (ID: {$id})";
}
return [
'ids' => $ids,
'names' => $names,
];
}
private function assertActiveContext(Tenant $tenant, BackupSet $backupSet): void
{
if (! $tenant->isActive()) {
throw new \RuntimeException('Tenant is archived or inactive.');
}
if ($backupSet->trashed()) {
throw new \RuntimeException('Backup set is archived.');
}
}
}