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
4 changed files with 180 additions and 0 deletions
Showing only changes of commit 07c0c0e861 - Show all commits

View File

@ -282,6 +282,7 @@ private function ignoredKeys(): array
'settingCount',
'settingsCount',
'templateReference',
'scheduledActionsForRule',
];
}

View File

@ -73,6 +73,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null
);
}
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 = [
@ -174,6 +184,60 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant
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
{
$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.
*/

View File

@ -15,6 +15,14 @@
'bitLockerEnabled' => false,
'osMinimumVersion' => '10.0.19045',
'activeFirewallRequired' => true,
'scheduledActionsForRule' => [
[
'ruleName' => 'Default rule',
'scheduledActionConfigurations' => [
['actionType' => 'notification'],
],
],
],
'customSetting' => 'Custom value',
];
@ -31,6 +39,8 @@
expect($additionalBlock)->not->toBeNull();
expect(collect($additionalBlock['rows'])->pluck('label')->all())
->toContain('Custom Setting');
expect(collect($additionalBlock['rows'])->pluck('label')->all())
->not->toContain('Scheduled Actions For Rule');
expect($settings->pluck('title')->all())->not->toContain('General');
});

View File

@ -0,0 +1,105 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class);
uses(RefreshDatabase::class);
class PolicySnapshotGraphClient implements GraphClientInterface
{
public array $requests = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->requests[] = ['getPolicy', $policyType, $policyId];
return new GraphResponse(success: true, data: [
'payload' => [
'id' => $policyId,
'displayName' => 'Compliance Alpha',
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
],
]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(success: true, data: []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$this->requests[] = [$method, $path];
if (str_contains($path, 'scheduledActionsForRule')) {
return new GraphResponse(success: true, data: [
'value' => [
[
'ruleName' => 'Default rule',
'scheduledActionConfigurations' => [
[
'actionType' => 'notification',
'notificationTemplateId' => 'template-123',
],
],
],
],
]);
}
return new GraphResponse(success: true, data: []);
}
}
it('hydrates compliance policy scheduled actions into snapshots', function () {
$client = new PolicySnapshotGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-compliance',
'app_client_id' => 'client-123',
'app_client_secret' => 'secret-123',
'is_current' => true,
]);
$tenant->makeCurrent();
$policy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'compliance-123',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Alpha',
'platform' => 'windows',
]);
$service = app(PolicySnapshotService::class);
$result = $service->fetch($tenant, $policy);
expect($result)->toHaveKey('payload');
expect($result['payload'])->toHaveKey('scheduledActionsForRule');
expect($result['payload']['scheduledActionsForRule'])->toHaveCount(1);
expect($result['payload']['scheduledActionsForRule'][0]['scheduledActionConfigurations'][0]['notificationTemplateId'])
->toBe('template-123');
expect($result['metadata']['compliance_actions_hydration'])->toBe('complete');
expect($client->requests)->toContain(['getPolicy', 'deviceCompliancePolicy', 'compliance-123']);
});