feat/018-driver-updates-wufb #27

Merged
ahmido merged 6 commits from feat/018-driver-updates-wufb into dev 2026-01-04 00:38:54 +00:00
11 changed files with 374 additions and 24 deletions
Showing only changes of commit a7d715c89e - Show all commits

View File

@ -42,6 +42,10 @@ ## Scope
name: "Quality Updates (Windows)" name: "Quality Updates (Windows)"
graph_resource: "deviceManagement/windowsQualityUpdateProfiles" graph_resource: "deviceManagement/windowsQualityUpdateProfiles"
- key: windowsDriverUpdateProfile
name: "Driver Updates (Windows)"
graph_resource: "deviceManagement/windowsDriverUpdateProfiles"
- key: deviceCompliancePolicy - key: deviceCompliancePolicy
name: "Device Compliance" name: "Device Compliance"
graph_resource: "deviceManagement/deviceCompliancePolicies" graph_resource: "deviceManagement/deviceCompliancePolicies"
@ -158,6 +162,11 @@ ## Scope
restore: enabled restore: enabled
risk: high risk: high
windowsDriverUpdateProfile:
backup: full
restore: enabled
risk: high
deviceCompliancePolicy: deviceCompliancePolicy:
backup: full backup: full
restore: enabled restore: enabled

View File

@ -13,6 +13,7 @@
use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer;
use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use App\Services\Intune\WindowsDriverUpdateProfileNormalizer;
use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer;
use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer;
use App\Services\Intune\WindowsUpdateRingNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer;
@ -49,6 +50,7 @@ public function register(): void
ManagedDeviceAppConfigurationNormalizer::class, ManagedDeviceAppConfigurationNormalizer::class,
ScriptsPolicyNormalizer::class, ScriptsPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class,
WindowsDriverUpdateProfileNormalizer::class,
WindowsFeatureUpdateProfileNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class,
WindowsQualityUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class,
WindowsUpdateRingNormalizer::class, WindowsUpdateRingNormalizer::class,

View File

@ -0,0 +1,125 @@
<?php
namespace App\Services\Intune;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class WindowsDriverUpdateProfileNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'windowsDriverUpdateProfile';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
if ($snapshot === []) {
return $normalized;
}
$block = $this->buildDriverUpdateBlock($snapshot);
if ($block !== null) {
$normalized['settings'][] = $block;
$normalized['settings'] = array_values(array_filter($normalized['settings']));
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$normalized = $this->normalize($snapshot, $policyType, $platform);
return $this->defaultNormalizer->flattenNormalizedForDiff($normalized);
}
private function buildDriverUpdateBlock(array $snapshot): ?array
{
$entries = [];
$displayName = Arr::get($snapshot, 'displayName');
if (is_string($displayName) && $displayName !== '') {
$entries[] = ['key' => 'Name', 'value' => $displayName];
}
$approvalType = Arr::get($snapshot, 'approvalType');
if (is_string($approvalType) && $approvalType !== '') {
$entries[] = ['key' => 'Approval type', 'value' => $approvalType];
}
$deferral = Arr::get($snapshot, 'deploymentDeferralInDays');
if (is_int($deferral) || (is_numeric($deferral) && (string) (int) $deferral === (string) $deferral)) {
$entries[] = ['key' => 'Deployment deferral (days)', 'value' => (int) $deferral];
}
$deviceReporting = Arr::get($snapshot, 'deviceReporting');
if (is_int($deviceReporting) || (is_numeric($deviceReporting) && (string) (int) $deviceReporting === (string) $deviceReporting)) {
$entries[] = ['key' => 'Devices reporting', 'value' => (int) $deviceReporting];
}
$newUpdates = Arr::get($snapshot, 'newUpdates');
if (is_int($newUpdates) || (is_numeric($newUpdates) && (string) (int) $newUpdates === (string) $newUpdates)) {
$entries[] = ['key' => 'New driver updates', 'value' => (int) $newUpdates];
}
$inventorySyncStatus = Arr::get($snapshot, 'inventorySyncStatus');
if (is_array($inventorySyncStatus)) {
$state = Arr::get($inventorySyncStatus, 'driverInventorySyncState');
if (is_string($state) && $state !== '') {
$entries[] = ['key' => 'Inventory sync state', 'value' => $state];
}
$lastSuccessful = $this->formatDateTime(Arr::get($inventorySyncStatus, 'lastSuccessfulSyncDateTime'));
if ($lastSuccessful !== null) {
$entries[] = ['key' => 'Last successful inventory sync', 'value' => $lastSuccessful];
}
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Driver Update Profile',
'entries' => $entries,
];
}
private function formatDateTime(mixed $value): ?string
{
if (! is_string($value) || $value === '') {
return null;
}
try {
return CarbonImmutable::parse($value)->toDateTimeString();
} catch (\Throwable) {
return $value;
}
}
}

View File

@ -296,6 +296,42 @@
'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE', 'assignments_delete_method' => 'DELETE',
], ],
'windowsDriverUpdateProfile' => [
'resource' => 'deviceManagement/windowsDriverUpdateProfiles',
'allowed_select' => [
'id',
'displayName',
'description',
'@odata.type',
'createdDateTime',
'lastModifiedDateTime',
'approvalType',
'deploymentDeferralInDays',
'roleScopeTagIds',
],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsDriverUpdateProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'update_strip_keys' => [
'deviceReporting',
'newUpdates',
'inventorySyncStatus',
],
'assignments_list_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'deviceCompliancePolicy' => [ 'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies', 'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],

View File

@ -64,6 +64,16 @@
'restore' => 'enabled', 'restore' => 'enabled',
'risk' => 'high', 'risk' => 'high',
], ],
[
'type' => 'windowsDriverUpdateProfile',
'label' => 'Driver Updates (Windows)',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/windowsDriverUpdateProfiles',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'high',
],
[ [
'type' => 'deviceCompliancePolicy', 'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance', 'label' => 'Device Compliance',

View File

@ -3,13 +3,12 @@ # Requirements Checklist (018)
**Created**: 2026-01-03 **Created**: 2026-01-03
**Feature**: [spec.md](../spec.md) **Feature**: [spec.md](../spec.md)
- [ ] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). - [x] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk).
- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). - [x] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths).
- [ ] Sync lists and stores driver update profiles in the Policies inventory. - [x] Sync lists and stores driver update profiles in the Policies inventory.
- [ ] Snapshot capture stores a complete payload for backups and versions. - [ ] Snapshot capture stores a complete payload for backups and versions.
- [ ] Restore preview is available and respects the configured restore mode. - [ ] Restore preview is available and respects the configured restore mode.
- [ ] Restore execution applies only patchable properties and records audit logs. - [x] Restore execution applies only patchable properties and records audit logs.
- [ ] Normalized settings view is readable for admins (no raw-only UX). - [x] Normalized settings view is readable for admins (no raw-only UX).
- [ ] Pest tests cover sync + snapshot + restore + normalized display. - [ ] Pest tests cover sync + snapshot + restore + normalized display.
- [ ] Pint run (`./vendor/bin/pint --dirty`) on touched files. - [x] Pint run (`./vendor/bin/pint --dirty`) on touched files.

View File

@ -27,12 +27,15 @@ ## Out of Scope (v1)
- Advanced reporting on driver compliance. - Advanced reporting on driver compliance.
- Partial per-setting restore. - Partial per-setting restore.
## Graph API Assumptions (to verify) ## Graph API Details (confirmed)
- **Resource**: `deviceManagement/windowsDriverUpdateProfiles` - **Resource**: `deviceManagement/windowsDriverUpdateProfiles`
- **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile` - **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile`
- **Assignments**: standard pattern with: - **Patchable fields**: `displayName`, `description`, `approvalType`, `deploymentDeferralInDays`, `roleScopeTagIds`
- **Read-only fields (strip on PATCH)**: `deviceReporting`, `newUpdates`, `inventorySyncStatus`, `createdDateTime`, `lastModifiedDateTime`
- **Assignments**:
- list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments` - list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments`
- assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign` - assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign`
- update/delete: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}`
## User Scenarios & Testing ## User Scenarios & Testing
@ -74,4 +77,3 @@ ### Non-Functional Requirements
- **NFR-001**: Preserve tenant isolation and least privilege. - **NFR-001**: Preserve tenant isolation and least privilege.
- **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit). - **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit).
- **NFR-003**: No new external services or dependencies. - **NFR-003**: No new external services or dependencies.

View File

@ -8,25 +8,25 @@ ## Phase 1: Setup
- [x] T001 Create/confirm spec, plan, tasks, checklist. - [x] T001 Create/confirm spec, plan, tasks, checklist.
## Phase 2: Research & Design ## Phase 2: Research & Design
- [ ] T002 Verify Graph resource + `@odata.type` for driver update profiles. - [x] T002 Verify Graph resource + `@odata.type` for driver update profiles.
- [ ] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. - [x] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`.
- [ ] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. - [x] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource.
- [ ] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. - [x] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability.
## Phase 3: Tests (TDD) ## Phase 3: Tests (TDD)
- [ ] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. - [x] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly.
- [ ] T007 Add snapshot/version capture test asserting full payload is stored. - [ ] T007 Add snapshot/version capture test asserting full payload is stored.
- [ ] T008 Add restore preview test for this type (entries + restore_mode shown). - [ ] T008 Add restore preview test for this type (entries + restore_mode shown).
- [ ] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. - [x] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata.
- [ ] T010 Add normalized display test for key fields. - [x] T010 Add normalized display test for key fields.
## Phase 4: Implementation ## Phase 4: Implementation
- [ ] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. - [x] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`.
- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`. - [x] T012 Add Graph contract entry in `config/graph_contracts.php`.
- [ ] T013 Implement any required snapshot hydration (if Graph uses subresources). - [ ] T013 Implement any required snapshot hydration (if Graph uses subresources).
- [ ] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). - [x] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization).
- [ ] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. - [x] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it.
## Phase 5: Verification ## Phase 5: Verification
- [ ] T016 Run targeted tests. - [x] T016 Run targeted tests.
- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). - [x] T017 Run Pint (`./vendor/bin/pint --dirty`).

View File

@ -211,3 +211,88 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName'); expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName');
}); });
test('restore execution applies windows driver update profile with sanitized payload', function () {
$client = new WindowsUpdateProfilesRestoreGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-driver',
'policy_type' => 'windowsDriverUpdateProfile',
'display_name' => 'Driver Updates A',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupPayload = [
'id' => 'policy-driver',
'@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile',
'displayName' => 'Driver Updates A',
'description' => 'Drivers rollout policy',
'approvalType' => 'automatic',
'deploymentDeferralInDays' => 7,
'deviceReporting' => 12,
'newUpdates' => 3,
'inventorySyncStatus' => [
'driverInventorySyncState' => 'success',
'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z',
],
'roleScopeTagIds' => ['0'],
];
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => $backupPayload,
]);
$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($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1);
expect($client->applyPolicyCalls)->toHaveCount(1);
expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsDriverUpdateProfile');
expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-driver');
expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH');
expect($client->applyPolicyCalls[0]['payload']['approvalType'])->toBe('automatic');
expect($client->applyPolicyCalls[0]['payload']['deploymentDeferralInDays'])->toBe(7);
expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']);
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deviceReporting');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('newUpdates');
expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('inventorySyncStatus');
});

View File

@ -62,7 +62,7 @@
$supported = config('tenantpilot.supported_policy_types'); $supported = config('tenantpilot.supported_policy_types');
$byType = collect($supported)->keyBy('type'); $byType = collect($supported)->keyBy('type');
expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']); expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile', 'windowsDriverUpdateProfile']);
expect($byType['deviceConfiguration']['filter'] ?? null) expect($byType['deviceConfiguration']['filter'] ?? null)
->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')");
@ -75,6 +75,50 @@
expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null)
->toBe('deviceManagement/windowsQualityUpdateProfiles'); ->toBe('deviceManagement/windowsQualityUpdateProfiles');
expect($byType['windowsDriverUpdateProfile']['endpoint'] ?? null)
->toBe('deviceManagement/windowsDriverUpdateProfiles');
});
it('syncs windows driver update profiles from Graph', function () {
$tenant = Tenant::factory()->create([
'status' => 'active',
]);
$logger = mock(GraphLogger::class);
$logger->shouldReceive('logRequest')
->zeroOrMoreTimes()
->andReturnNull();
$logger->shouldReceive('logResponse')
->zeroOrMoreTimes()
->andReturnNull();
mock(GraphClientInterface::class)
->shouldReceive('listPolicies')
->once()
->with('windowsDriverUpdateProfile', mockery::type('array'))
->andReturn(new GraphResponse(
success: true,
data: [
[
'id' => 'wdp-1',
'displayName' => 'Driver Updates A',
'@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile',
'approvalType' => 'automatic',
],
],
));
$service = app(PolicySyncService::class);
$service->syncPolicies($tenant, [
['type' => 'windowsDriverUpdateProfile', 'platform' => 'windows'],
]);
expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'windowsDriverUpdateProfile')->count())
->toBe(1);
}); });
it('includes managed device app configurations in supported types', function () { it('includes managed device app configurations in supported types', function () {

View File

@ -0,0 +1,38 @@
<?php
use App\Services\Intune\PolicyNormalizer;
use Tests\TestCase;
uses(TestCase::class);
it('normalizes windows driver update profiles into readable settings', function () {
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile',
'displayName' => 'Driver Updates A',
'description' => 'Drivers rollout policy',
'approvalType' => 'automatic',
'deploymentDeferralInDays' => 7,
'deviceReporting' => 12,
'newUpdates' => 3,
'inventorySyncStatus' => [
'driverInventorySyncState' => 'success',
'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z',
],
];
$result = $normalizer->normalize($snapshot, 'windowsDriverUpdateProfile', 'windows');
expect($result['status'])->toBe('success');
expect($result['settings'])->toBeArray()->not->toBeEmpty();
$driverBlock = collect($result['settings'])
->first(fn (array $block) => ($block['title'] ?? null) === 'Driver Update Profile');
expect($driverBlock)->not->toBeNull();
$keys = collect($driverBlock['entries'] ?? [])->pluck('key')->all();
expect($keys)->toContain('Approval type', 'Deployment deferral (days)', 'Devices reporting', 'New driver updates');
});