Compare commits
2 Commits
8aa9fd4d0f
...
426a59e00b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
426a59e00b | ||
|
|
a985bff287 |
@ -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_';
|
||||
|
||||
@ -88,6 +88,32 @@
|
||||
{{ $item['compliance_action_warning'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
|
||||
@php
|
||||
$summary = $item['compliance_action_summary'];
|
||||
$missingTemplates = $item['compliance_action_missing_templates'] ?? [];
|
||||
$total = (int) ($summary['total'] ?? 0);
|
||||
$missing = (int) ($summary['missing'] ?? 0);
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
Compliance notifications: {{ $total }} total • {{ $missing }} missing
|
||||
</div>
|
||||
|
||||
@if (! empty($missingTemplates) && is_array($missingTemplates))
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Missing notification templates</summary>
|
||||
<div class="mt-2 space-y-1">
|
||||
@foreach ($missingTemplates as $templateId)
|
||||
<div class="rounded border border-amber-200 bg-white px-2 py-1 text-[11px] text-gray-800">
|
||||
{{ $templateId }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@ -192,10 +192,10 @@
|
||||
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
|
||||
@php
|
||||
$summary = $item['compliance_action_summary'];
|
||||
$complianceOutcomes = $item['compliance_action_outcomes'] ?? [];
|
||||
$complianceIssues = collect($complianceOutcomes)
|
||||
->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped')
|
||||
->values();
|
||||
$complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null)
|
||||
? $item['compliance_action_outcomes']
|
||||
: [];
|
||||
$complianceEntries = collect($complianceOutcomes)->values();
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
@ -203,18 +203,26 @@
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
</div>
|
||||
|
||||
@if ($complianceIssues->isNotEmpty())
|
||||
@if ($complianceEntries->isNotEmpty())
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($complianceIssues as $outcome)
|
||||
@foreach ($complianceEntries as $outcome)
|
||||
@php
|
||||
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
||||
$outcomeColor = match ($outcomeStatus) {
|
||||
'mapped' => 'text-green-700 bg-green-100 border-green-200',
|
||||
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||
};
|
||||
@endphp
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Template {{ $outcome['template_id'] ?? 'unknown' }}
|
||||
</div>
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-900 bg-amber-100 border-amber-200">
|
||||
skipped
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
||||
{{ $outcomeStatus }}
|
||||
</span>
|
||||
</div>
|
||||
@if (! empty($outcome['rule_name']))
|
||||
@ -222,6 +230,11 @@
|
||||
Rule: {{ $outcome['rule_name'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($outcome['mapped_template_id']))
|
||||
<div class="mt-1 text-[11px] text-gray-700">
|
||||
Mapped to: {{ $outcome['mapped_template_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (! empty($outcome['reason']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
{{ $outcome['reason'] }}
|
||||
|
||||
@ -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`.
|
||||
@ -54,8 +54,8 @@ ## Phase 4: Admin UX
|
||||
|
||||
**Purpose**: Surface restore and compliance details clearly in the UI.
|
||||
|
||||
- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings.
|
||||
- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons.
|
||||
- [x] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings.
|
||||
- [x] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons.
|
||||
|
||||
**Checkpoint**: Admins can see compliance related mapping results in preview and results.
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -188,4 +188,5 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
|
||||
expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull();
|
||||
expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1);
|
||||
expect($policyPreview['compliance_action_missing_templates'] ?? [])->toContain('template-1');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user