feat(007): device config & compliance snapshot/restore improvements #9

Merged
ahmido merged 18 commits from feat/007-device-config-compliance into dev 2025-12-29 12:46:20 +00:00
3 changed files with 341 additions and 5 deletions
Showing only changes of commit a985bff287 - Show all commits

View File

@ -242,6 +242,7 @@ public function execute(
'client_secret' => $tenant->app_client_secret,
'platform' => $item->platform,
];
$updateMethod = $this->resolveUpdateMethod($item->policy_type);
$settingsApply = null;
$itemStatus = 'applied';
@ -249,6 +250,7 @@ public function execute(
$resultReason = null;
$createdPolicyId = null;
$createdPolicyMode = null;
$settingsApplyEligible = false;
if ($item->policy_type === 'settingsCatalogPolicy') {
$settings = $this->extractSettingsCatalogSettings($originalPayload);
@ -258,10 +260,55 @@ public function execute(
$item->policy_type,
$item->policy_identifier,
$policyPayload,
$graphOptions
$graphOptions + ['method' => $updateMethod]
);
if ($response->successful() && $settings !== []) {
$settingsApplyEligible = $response->successful();
if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) {
$createOutcome = $this->createSettingsCatalogPolicy(
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(
policyId: $item->policy_identifier,
settings: $settings,
@ -314,7 +361,7 @@ public function execute(
];
}
}
} elseif ($settings !== []) {
} elseif ($settingsApplyEligible && $settings !== []) {
$settingsApply = [
'total' => count($settings),
'applied' => 0,
@ -328,7 +375,7 @@ public function execute(
$item->policy_type,
$item->policy_identifier,
$payload,
$graphOptions
$graphOptions + ['method' => $updateMethod]
);
if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
@ -349,6 +396,26 @@ public function execute(
$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) {
@ -602,6 +669,52 @@ private function resolveRestoreMode(string $policyType): string
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 ($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>>
@ -1359,6 +1472,70 @@ private function createSettingsCatalogPolicy(
];
}
/**
* @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 (! 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}
*/
@ -1525,6 +1702,63 @@ private function buildSettingsCatalogCreatePayload(
return $payload;
}
/**
* @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 prefixRestoredName(?string $name, string $fallback): string
{
$prefix = 'Restored_';

View File

@ -41,7 +41,7 @@ ## Phase 3: Restore Logic and Mapping
**Purpose**: Restore new policy types safely using assignment and foundation mappings.
- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
- [x] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types.
- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`.
- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`.

View File

@ -392,3 +392,105 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
});
test('restore execution creates missing policy using contracts', function () {
$graphClient = new class implements GraphClientInterface
{
public int $applyCalls = 0;
public int $createCalls = 0;
public array $createPayloads = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyCalls++;
return new GraphResponse(false, [], 404, [], [], [
'error_code' => 'ResourceNotFound',
'error_message' => 'Resource not found.',
]);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
if ($method === 'POST' && $path === 'deviceManagement/deviceCompliancePolicies') {
$this->createCalls++;
$this->createPayloads[] = $options['json'] ?? [];
return new GraphResponse(true, ['id' => 'compliance-created']);
}
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
};
app()->instance(GraphClientInterface::class, $graphClient);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-4',
'name' => 'Tenant Four',
'metadata' => [],
]);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => 'compliance-1',
'policy_type' => 'deviceCompliancePolicy',
'platform' => 'windows',
'payload' => [
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
'displayName' => 'Compliance Policy',
'description' => 'Test policy',
],
])
->create();
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
);
expect($graphClient->applyCalls)->toBe(1);
expect($graphClient->createCalls)->toBe(1);
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy');
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('compliance-created');
});