Problem: Restore nutzt bisher den Snapshot aus dem BackupSet (BackupItem). Wenn der Snapshot “unvollständig”/nicht der gewünschte Stand ist, landen nach Restore nur wenige Admin-Template-Settings in Intune. Lösung: Neue Action “Restore to Intune” direkt an einer konkreten PolicyVersion (inkl. Dry-Run Toggle) → reproduzierbarer Rollback auf exakt diese Version. Restore-UI zeigt jetzt PolicyVersion-Nummer (version: X) in der Item-Auswahl + BackupSet Items Tabelle hat eine Version-Spalte. Implementierung: RestoreService::executeFromPolicyVersion() erzeugt dafür einen kleinen, temporären BackupSet+BackupItem aus der Version und startet einen normalen RestoreRun. Pest-Test: PolicyVersionRestoreToIntuneTest.php Specs/TODO: Offene Follow-ups sind dokumentiert in tasks.md unter “Open TODOs (Follow-up)”. QA (GUI): Inventory → Policies → <Policy> → Versions → Restore to Intune (erst Dry-Run, dann Execute) Backups & Restore → Restore Runs → Create (bei Items steht version: X) Backups & Restore → Backup Sets → <Set> (Version-Spalte) Tests: PolicyVersionRestoreToIntuneTest.php Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #13
537 lines
19 KiB
PHP
537 lines
19 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 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);
|
|
|
|
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 === 'settingsCatalogPolicy') {
|
|
[$payload, $metadata] = $this->hydrateSettingsCatalog(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata
|
|
);
|
|
}
|
|
|
|
if ($policy->policy_type === 'groupPolicyConfiguration') {
|
|
[$payload, $metadata] = $this->hydrateGroupPolicyConfiguration(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata
|
|
);
|
|
}
|
|
|
|
if ($policy->policy_type === 'deviceCompliancePolicy') {
|
|
[$payload, $metadata] = $this->hydrateComplianceActions(
|
|
tenantIdentifier: $tenantIdentifier,
|
|
tenant: $tenant,
|
|
policyId: $policy->external_id,
|
|
payload: is_array($payload) ? $payload : [],
|
|
metadata: $metadata
|
|
);
|
|
}
|
|
|
|
if ($response->failed()) {
|
|
$reason = $response->warnings[0] ?? 'Graph request failed';
|
|
$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 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 settings catalog policies with configuration settings subresource.
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy');
|
|
$settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId);
|
|
|
|
if ($strategy !== 'subresource_settings' || ! $settingsPath) {
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$settings = [];
|
|
$nextPath = $settingsPath;
|
|
$hydrationStatus = 'complete';
|
|
|
|
while ($nextPath) {
|
|
$response = $this->graphClient->request('GET', $nextPath, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
$hydrationStatus = 'failed';
|
|
|
|
break;
|
|
}
|
|
|
|
$data = $response->data;
|
|
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
|
$settings = array_merge($settings, $pageItems);
|
|
$nextLink = $data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $nextLink) {
|
|
break;
|
|
}
|
|
|
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
|
}
|
|
|
|
if (! empty($settings)) {
|
|
$payload['settings'] = $settings;
|
|
|
|
// Extract definition IDs and warm cache (T008-T010)
|
|
$definitionIds = $this->extractDefinitionIds($settings);
|
|
$metadata['definition_count'] = count($definitionIds);
|
|
|
|
// Warm cache for definitions (non-blocking)
|
|
$this->definitionResolver->warmCache($definitionIds);
|
|
$metadata['definitions_cached'] = true;
|
|
}
|
|
|
|
$metadata['settings_hydration'] = $hydrationStatus;
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* Hydrate Administrative Templates (Group Policy Configurations) with definitionValues and presentationValues.
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateGroupPolicyConfiguration(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$strategy = $this->contracts->memberHydrationStrategy('groupPolicyConfiguration');
|
|
$definitionValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'definitionValues', [
|
|
'{id}' => $policyId,
|
|
]);
|
|
|
|
if ($strategy !== 'subresource_definition_values' || ! $definitionValuesPath) {
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$graphBase = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
|
|
.'/'.trim((string) config('graph.version', 'beta'), '/');
|
|
$definitionValues = [];
|
|
$nextPath = $definitionValuesPath;
|
|
$hydrationStatus = 'complete';
|
|
|
|
while ($nextPath) {
|
|
$response = $this->graphClient->request('GET', $nextPath, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
$hydrationStatus = 'failed';
|
|
break;
|
|
}
|
|
|
|
$definitionValues = array_merge($definitionValues, $response->data['value'] ?? []);
|
|
$nextLink = $response->data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $nextLink) {
|
|
break;
|
|
}
|
|
|
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
|
}
|
|
|
|
if ($hydrationStatus === 'failed') {
|
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
|
$metadata['warnings'] ?? [],
|
|
['Hydration failed: could not load Administrative Templates definition values.']
|
|
)));
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$settings = [];
|
|
|
|
foreach ($definitionValues as $definitionValue) {
|
|
if (! is_array($definitionValue)) {
|
|
continue;
|
|
}
|
|
|
|
$definition = $definitionValue['definition'] ?? null;
|
|
$definitionId = is_array($definition) ? ($definition['id'] ?? null) : null;
|
|
$definitionValueId = $definitionValue['id'] ?? null;
|
|
|
|
if (! is_string($definitionValueId) || $definitionValueId === '') {
|
|
continue;
|
|
}
|
|
|
|
if (! is_string($definitionId) || $definitionId === '') {
|
|
continue;
|
|
}
|
|
|
|
$presentationValuesPath = $this->contracts->subresourcePath('groupPolicyConfiguration', 'presentationValues', [
|
|
'{id}' => $policyId,
|
|
'{definitionValueId}' => $definitionValueId,
|
|
]);
|
|
|
|
$setting = [
|
|
'enabled' => (bool) ($definitionValue['enabled'] ?? false),
|
|
'definition@odata.bind' => "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')",
|
|
'#Definition_Id' => $definitionId,
|
|
'#Definition_displayName' => is_array($definition) ? ($definition['displayName'] ?? null) : null,
|
|
'#Definition_classType' => is_array($definition) ? ($definition['classType'] ?? null) : null,
|
|
'#Definition_categoryPath' => is_array($definition) ? ($definition['categoryPath'] ?? null) : null,
|
|
];
|
|
|
|
$setting = array_filter($setting, static fn ($value) => $value !== null);
|
|
|
|
if (! $presentationValuesPath) {
|
|
$settings[] = $setting;
|
|
|
|
continue;
|
|
}
|
|
|
|
$presentationValues = [];
|
|
$presentationNext = $presentationValuesPath;
|
|
|
|
while ($presentationNext) {
|
|
$pvResponse = $this->graphClient->request('GET', $presentationNext, [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
]);
|
|
|
|
if ($pvResponse->failed()) {
|
|
$metadata['warnings'] = array_values(array_unique(array_merge(
|
|
$metadata['warnings'] ?? [],
|
|
['Hydration warning: could not load some Administrative Templates presentation values.']
|
|
)));
|
|
break;
|
|
}
|
|
|
|
$presentationValues = array_merge($presentationValues, $pvResponse->data['value'] ?? []);
|
|
$presentationNextLink = $pvResponse->data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $presentationNextLink) {
|
|
break;
|
|
}
|
|
|
|
$presentationNext = $this->stripGraphBaseUrl((string) $presentationNextLink);
|
|
}
|
|
|
|
if ($presentationValues !== []) {
|
|
$setting['presentationValues'] = [];
|
|
|
|
foreach ($presentationValues as $presentationValue) {
|
|
if (! is_array($presentationValue)) {
|
|
continue;
|
|
}
|
|
|
|
$presentation = $presentationValue['presentation'] ?? null;
|
|
$presentationId = is_array($presentation) ? ($presentation['id'] ?? null) : null;
|
|
|
|
if (! is_string($presentationId) || $presentationId === '') {
|
|
continue;
|
|
}
|
|
|
|
$cleanPresentationValue = Arr::except($presentationValue, [
|
|
'presentation',
|
|
'id',
|
|
'lastModifiedDateTime',
|
|
'createdDateTime',
|
|
]);
|
|
|
|
$cleanPresentationValue['presentation@odata.bind'] = "{$graphBase}/deviceManagement/groupPolicyDefinitions('{$definitionId}')/presentations('{$presentationId}')";
|
|
|
|
$label = is_array($presentation) ? ($presentation['label'] ?? null) : null;
|
|
|
|
if (is_string($label) && $label !== '') {
|
|
$cleanPresentationValue['#Presentation_Label'] = $label;
|
|
}
|
|
|
|
$cleanPresentationValue['#Presentation_Id'] = $presentationId;
|
|
|
|
$setting['presentationValues'][] = $cleanPresentationValue;
|
|
}
|
|
|
|
if ($setting['presentationValues'] === []) {
|
|
unset($setting['presentationValues']);
|
|
}
|
|
}
|
|
|
|
$settings[] = $setting;
|
|
}
|
|
|
|
$payload['definitionValues'] = $settings;
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* Hydrate compliance policies with scheduled actions (notification templates).
|
|
*
|
|
* @return array{0:array,1:array}
|
|
*/
|
|
private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array
|
|
{
|
|
$existingActions = $payload['scheduledActionsForRule'] ?? null;
|
|
|
|
if (is_array($existingActions) && $existingActions !== []) {
|
|
$metadata['compliance_actions_hydration'] = 'embedded';
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
$path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId));
|
|
$options = [
|
|
'tenant' => $tenantIdentifier,
|
|
'client_id' => $tenant->app_client_id,
|
|
'client_secret' => $tenant->app_client_secret,
|
|
];
|
|
|
|
$actions = [];
|
|
$nextPath = $path;
|
|
$hydrationStatus = 'complete';
|
|
|
|
while ($nextPath) {
|
|
$response = $this->graphClient->request('GET', $nextPath, $options);
|
|
|
|
if ($response->failed()) {
|
|
$hydrationStatus = 'failed';
|
|
|
|
break;
|
|
}
|
|
|
|
$data = $response->data;
|
|
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
|
|
|
foreach ($pageItems as $item) {
|
|
if (is_array($item)) {
|
|
$actions[] = $item;
|
|
}
|
|
}
|
|
|
|
$nextLink = $data['@odata.nextLink'] ?? null;
|
|
|
|
if (! $nextLink) {
|
|
break;
|
|
}
|
|
|
|
$nextPath = $this->stripGraphBaseUrl((string) $nextLink);
|
|
}
|
|
|
|
if (! empty($actions)) {
|
|
$payload['scheduledActionsForRule'] = $actions;
|
|
}
|
|
|
|
$metadata['compliance_actions_hydration'] = $hydrationStatus;
|
|
|
|
return [$payload, $metadata];
|
|
}
|
|
|
|
/**
|
|
* Extract all settingDefinitionId from settings array, including nested children.
|
|
*/
|
|
private function extractDefinitionIds(array $settings): array
|
|
{
|
|
$definitionIds = [];
|
|
|
|
foreach ($settings as $setting) {
|
|
// Extract definition ID from settingInstance
|
|
if (isset($setting['settingInstance']['settingDefinitionId'])) {
|
|
$definitionIds[] = $setting['settingInstance']['settingDefinitionId'];
|
|
}
|
|
|
|
// Handle groupSettingCollectionInstance with children
|
|
if (isset($setting['settingInstance']['@odata.type']) &&
|
|
str_contains($setting['settingInstance']['@odata.type'], 'groupSettingCollectionInstance')) {
|
|
if (isset($setting['settingInstance']['groupSettingCollectionValue'])) {
|
|
foreach ($setting['settingInstance']['groupSettingCollectionValue'] as $group) {
|
|
if (isset($group['children'])) {
|
|
$childIds = $this->extractDefinitionIds($group['children']);
|
|
$definitionIds = array_merge($definitionIds, $childIds);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_unique($definitionIds);
|
|
}
|
|
|
|
private function stripGraphBaseUrl(string $nextLink): string
|
|
{
|
|
$base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/').'/'.trim(config('graph.version', 'beta'), '/');
|
|
|
|
if (str_starts_with($nextLink, $base)) {
|
|
return ltrim(substr($nextLink, strlen($base)), '/');
|
|
}
|
|
|
|
return ltrim($nextLink, '/');
|
|
}
|
|
}
|