From 662b0e0aa8ec4268a73633fb527bdf10e1d7be71 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 00:18:34 +0100 Subject: [PATCH 1/5] spec: refresh specs/plans + add 018 scaffolding --- .specify/memory/constitution.md | 63 +++---- .specify/plan.md | 26 +-- .specify/spec.md | 155 ++++++++++++++++-- .specify/tasks.md | 16 +- .../checklists/requirements.md | 15 ++ specs/018-driver-updates-wufb/plan.md | 24 +++ specs/018-driver-updates-wufb/spec.md | 77 +++++++++ specs/018-driver-updates-wufb/tasks.md | 32 ++++ .../checklists/requirements.md | 17 +- specs/023-endpoint-security-restore/plan.md | 3 +- specs/023-endpoint-security-restore/spec.md | 4 +- specs/023-endpoint-security-restore/tasks.md | 35 ++-- 12 files changed, 368 insertions(+), 99 deletions(-) create mode 100644 specs/018-driver-updates-wufb/checklists/requirements.md create mode 100644 specs/018-driver-updates-wufb/plan.md create mode 100644 specs/018-driver-updates-wufb/spec.md create mode 100644 specs/018-driver-updates-wufb/tasks.md diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a4670ff..8670192 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,50 +1,35 @@ -# [PROJECT_NAME] Constitution - +# TenantPilot Constitution ## Core Principles -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - +### Safety-First Restore +- Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary. +- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist. +- Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item. -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - +### Auditability & Tenant Isolation +- Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens). +- Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant). -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - +### Graph Abstraction & Contracts +- All Microsoft Graph calls MUST go through `GraphClientInterface`. +- Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code. +- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`. -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - +### Least Privilege +- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected. +- Never store secrets in code/config; never log credentials or tokens. -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - +### Spec-First Workflow +- For any feature that changes runtime behavior, include or update `specs/-/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`. +- New work branches from `dev` using `feat/-` (spec + code in the same PR). -## [SECTION_2_NAME] - - -[SECTION_2_CONTENT] - - -## [SECTION_3_NAME] - - -[SECTION_3_CONTENT] - +## Quality Gates +- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`. +- Run `./vendor/bin/pint --dirty` before finalizing. ## Governance - +- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones. +- Restore semantics changes require: spec update, checklist update, and tests proving safety. -[GOVERNANCE_RULES] - - -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03 diff --git a/.specify/plan.md b/.specify/plan.md index d5d5e5b..eb4bfe3 100644 --- a/.specify/plan.md +++ b/.specify/plan.md @@ -1,16 +1,16 @@ # Implementation Plan: TenantPilot v1 -**Branch**: `tenantpilot-v1` -**Date**: 2025-12-12 -**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged) +**Branch**: `dev` +**Date**: 2026-01-03 +**Spec Source**: `.specify/spec.md` (scope/restore matrix is config-driven) ## Summary -TenantPilot v1 already delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, and Highlander enforcement. Remaining priority work is the delegated Intune RBAC onboarding wizard (US7) and afterwards the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). +TenantPilot v1 delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, Highlander enforcement, the delegated RBAC onboarding wizard (US7), and the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). ## Status Snapshot (tasks.md is source of truth) -- **Done**: US1 inventory, US2 backups, US3 versions/diffs, US4 restore preview/exec, scope config, soft-deletes/housekeeping, Highlander single current tenant, tenant setup & verify (US6), permissions/health overview (US6), table ActionGroup UX, settings normalization/display (US1b), Dokploy/Sail runbooks. -- **Next up**: **US7** Intune RBAC onboarding wizard (delegated, synchronous Filament flow). -- **Upcoming**: **US8** Graph Contract Registry & Drift Guard (contract registry, type-family handling, verification command, fallback strategies). +- **Done**: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, permissions/health, housekeeping/UX, ops). +- **Open**: T167 (optional) CLI/Job for CHECK/REPORT only (no grant). +- **Next up**: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`. ## Technical Baseline - Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL. @@ -28,10 +28,12 @@ ## Completed Workstreams (no new action needed) - **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants. - **US6 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail. - **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section. +- **US7 RBAC Wizard (Phase 14)**: Delegated, synchronous onboarding wizard with post-verify canary checks and audit trail. +- **US8 Graph Contracts & Drift Guard (Phase 15)**: Config-driven contract registry, type-family handling, capability downgrade fallbacks, and a drift-check command. - **Housekeeping/UX (Phases 10–12)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline. - **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured. -## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) +## Completed: US7 Intune RBAC Onboarding Wizard (Phase 14) - Objectives: deliver delegated, tenant-scoped wizard that safely converges the Intune RBAC state for the configured service principal; fully audited, idempotent, least-privilege by default. - Scope alignment: FR-023–FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache. @@ -56,7 +58,7 @@ ## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) - Health integration: Verify reflects RBAC status and prompts to run wizard when missing. - Deployment/ops: no new env vars; ensure migrations for tenant RBAC columns are applied; run targeted tests `php artisan test tests/Unit/RbacOnboardingServiceTest.php tests/Feature/Filament/TenantRbacWizardTest.php`; Pint on touched files. -## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) +## Completed: US8 Graph Contract Registry & Drift Guard (Phase 15) - Objectives: centralize Graph contract assumptions per supported type/endpoint and provide drift detection + safe fallbacks so preview/restore remain stable on Graph shape/capability changes. - Scope alignment: FR-031–FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware). @@ -74,7 +76,7 @@ ## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) - Testing outline: unit for registry lookups/type-family matching/fallback selection; integration/Pest to simulate capability errors and ensure downgrade path + correct routing for derived types. ## Testing & Quality Gates -- Continue using targeted Pest runs per change set; add/extend tests for US7 wizard now, and for US8 contracts when implemented. +- Continue using targeted Pest runs per change set; add/extend tests when RBAC/contract behavior changes. - Run Pint on touched files before finalizing. - Maintain tenant isolation, audit logging, and restore safety gates; validate snapshot shape and type-family compatibility prior to restore execution. @@ -83,6 +85,6 @@ ### Restore Safety Gate - Restore preview MAY still render details + warnings for out-of-family snapshots, but MUST NOT offer an apply action. ## Coordination -- Update `.specify/tasks.md` to reflect progress on US7 wizard and future US8 contract tasks; no new entities or scope changes introduced here. +- Keep `.specify/tasks.md` and per-feature specs under `specs/` aligned with implementation changes. - Stage validation required before production for any migration or restore-impacting change. -- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). \ No newline at end of file +- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). diff --git a/.specify/spec.md b/.specify/spec.md index 6ba9f44..e664e78 100644 --- a/.specify/spec.md +++ b/.specify/spec.md @@ -1,20 +1,46 @@ # Feature Specification: TenantPilot v1 -**Feature Branch**: `tenantpilot-v1` +**Feature Branch**: `dev` **Created**: 2025-12-10 -**Status**: Draft +**Status**: Active +**Last Updated**: 2026-01-03 **Input**: TenantPilot v1 scope covering Intune configuration inventory (config, compliance, scripts, apps, conditional access, endpoint security, enrollment/autopilot, RBAC), backup, version history, and defensive restore for Intune administrators. ## Scope ```yaml scope: - description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können." + description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können. Single Source of Truth: config/tenantpilot.php + config/graph_contracts.php." supported_types: - key: deviceConfiguration name: "Device Configuration" graph_resource: "deviceManagement/deviceConfigurations" - notes: "Inklusive Custom OMA-URI, Administrative Templates und Settings Catalog." + filter: "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')" + notes: "Standard Device Config inkl. Custom OMA-URI; excludes WUfB Update Rings." + + - key: groupPolicyConfiguration + name: "Administrative Templates" + graph_resource: "deviceManagement/groupPolicyConfigurations" + notes: "Administrative Templates (Group Policy)." + + - key: settingsCatalogPolicy + name: "Settings Catalog Policy" + graph_resource: "deviceManagement/configurationPolicies" + notes: "Settings Catalog policies; settings are hydrated from the /settings subresource." + + - key: windowsUpdateRing + name: "Software Update Ring" + graph_resource: "deviceManagement/deviceConfigurations" + filter: "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')" + notes: "Windows Update for Business (WUfB) update rings." + + - key: windowsFeatureUpdateProfile + name: "Feature Updates (Windows)" + graph_resource: "deviceManagement/windowsFeatureUpdateProfiles" + + - key: windowsQualityUpdateProfile + name: "Quality Updates (Windows)" + graph_resource: "deviceManagement/windowsQualityUpdateProfiles" - key: deviceCompliancePolicy name: "Device Compliance" @@ -25,6 +51,16 @@ ## Scope graph_resource: "deviceAppManagement/managedAppPolicies" notes: "iOS und Android Managed App Protection." + - key: mamAppConfiguration + name: "App Configuration (MAM)" + graph_resource: "deviceAppManagement/targetedManagedAppConfigurations" + notes: "App configuration targeting managed apps (MAM)." + + - key: managedDeviceAppConfiguration + name: "App Configuration (Device)" + graph_resource: "deviceAppManagement/mobileAppConfigurations" + notes: "Managed device app configuration profiles." + - key: conditionalAccessPolicy name: "Conditional Access" graph_resource: "identity/conditionalAccess/policies" @@ -35,6 +71,14 @@ ## Scope graph_resource: "deviceManagement/deviceManagementScripts" notes: "scriptContent wird beim Backup base64-decoded gespeichert und beim Restore wieder encoded (vgl. FR-020)." + - key: deviceShellScript + name: "macOS Shell Scripts" + graph_resource: "deviceManagement/deviceShellScripts" + + - key: deviceHealthScript + name: "Proactive Remediations" + graph_resource: "deviceManagement/deviceHealthScripts" + - key: enrollmentRestriction name: "Enrollment Restrictions" graph_resource: "deviceManagement/deviceEnrollmentConfigurations" @@ -46,22 +90,40 @@ ## Scope - key: windowsEnrollmentStatusPage name: "Enrollment Status Page (ESP)" graph_resource: "deviceManagement/deviceEnrollmentConfigurations" - filter: "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'" + notes: "Filtered to #microsoft.graph.windows10EnrollmentCompletionPageConfiguration." - key: endpointSecurityIntent name: "Endpoint Security Intents" graph_resource: "deviceManagement/intents" notes: "Account Protection, Disk Encryption etc.; Zuordnung über bekannte Templates." + - key: endpointSecurityPolicy + name: "Endpoint Security Policies" + graph_resource: "deviceManagement/configurationPolicies" + notes: "Configuration policies classified via technologies/templateReference; restore execution enabled with template validation (Feature 023)." + + - key: securityBaselinePolicy + name: "Security Baselines" + graph_resource: "deviceManagement/configurationPolicies" + notes: "High risk; v1 restore stays preview-only." + - key: mobileApp name: "Applications (Metadata only)" graph_resource: "deviceAppManagement/mobileApps" notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)." - - key: settingsCatalogPolicy - name: "Settings Catalog Policy" - graph_resource: "deviceManagement/configurationPolicies" - notes: "Intune Settings Catalog Policies liegen NICHT unter deviceConfigurations, sondern unter configurationPolicies. v1 behandelt sie als eigenen Typ." + foundation_types: + - key: assignmentFilter + name: "Assignment Filter" + graph_resource: "deviceManagement/assignmentFilters" + + - key: roleScopeTag + name: "Scope Tag" + graph_resource: "deviceManagement/roleScopeTags" + + - key: notificationMessageTemplate + name: "Notification Message Template" + graph_resource: "deviceManagement/notificationMessageTemplates" restore_matrix: deviceConfiguration: @@ -70,6 +132,32 @@ ## Scope risk: medium notes: "Standard-Case für Backup+Restore; starke Preview/Audit Pflicht." + groupPolicyConfiguration: + backup: full + restore: enabled + risk: medium + + settingsCatalogPolicy: + backup: full + restore: enabled + risk: medium + notes: "Settings are applied via configurationPolicies/{id}/settings; capability fallbacks may require manual follow-up." + + windowsUpdateRing: + backup: full + restore: enabled + risk: medium-high + + windowsFeatureUpdateProfile: + backup: full + restore: enabled + risk: high + + windowsQualityUpdateProfile: + backup: full + restore: enabled + risk: high + deviceCompliancePolicy: backup: full restore: enabled @@ -82,6 +170,16 @@ ## Scope risk: medium-high notes: "MAM-Änderungen wirken auf Datenzugriff in Apps; Preview und Diff wichtig." + mamAppConfiguration: + backup: full + restore: enabled + risk: medium-high + + managedDeviceAppConfiguration: + backup: full + restore: enabled + risk: medium-high + conditionalAccessPolicy: backup: full restore: preview-only @@ -94,6 +192,16 @@ ## Scope risk: medium notes: "Script-Inhalt und Einstellungen werden gesichert; Decode/Encode beachten." + deviceShellScript: + backup: full + restore: enabled + risk: medium + + deviceHealthScript: + backup: full + restore: enabled + risk: medium + enrollmentRestriction: backup: full restore: preview-only @@ -118,17 +226,38 @@ ## Scope risk: high notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig." - settingsCatalogPolicy: + endpointSecurityPolicy: backup: full - restore: enableds - risk: medium - notes: "Settings Catalog Policies sind Standard-Config-Policies (Settings Catalog). Preview/Audit Pflicht; Restore automatisierbar." + restore: enabled + risk: high + notes: "Enabled with template validation (Feature 023)." + + securityBaselinePolicy: + backup: full + restore: preview-only + risk: high + notes: "High risk; preview-only by default." mobileApp: backup: metadata-only restore: enabled risk: low-medium notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder." + + assignmentFilter: + backup: full + restore: enabled + risk: low + + roleScopeTag: + backup: full + restore: enabled + risk: low + + notificationMessageTemplate: + backup: full + restore: enabled + risk: low ``` ## User Scenarios & Testing *(mandatory)* diff --git a/.specify/tasks.md b/.specify/tasks.md index d369dae..701dd6f 100644 --- a/.specify/tasks.md +++ b/.specify/tasks.md @@ -8,9 +8,9 @@ # Tasks: TenantPilot v1 **Prerequisites**: plan.md (complete), spec.md (complete) **Status snapshot** -- Done: Phases 1–13 (US1–US4, Settings normalization/display, Highlander, US6 permissions/health, housekeeping/UX, ops) -- Next up: Phase 14 (US7) delegated Intune RBAC onboarding wizard (synchronous) -- Upcoming: Phase 15 (US8) Graph Contract Registry & Drift Guard +- Done: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, US6 permissions/health, housekeeping/UX, ops) +- Open: T167 (optional) CLI/Job for CHECK/REPORT only (no grant) +- Next up: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/` --- @@ -188,7 +188,7 @@ ## Acceptance Criteria - Restore von `settingsCatalogPolicy` scheitert nicht mehr an `Platforms`. - Results zeigen bei Fehlern weiterhin request-id/client-request-id (bleibt wie T177). -- [ ] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display +- [x] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display - **Goal:** Für `settingsCatalogPolicy` sollen die **Configuration settings** (wie im Intune Portal unter *Configuration settings*) im System sichtbar sein: - in **Policy Version Raw JSON** enthalten @@ -278,7 +278,7 @@ ## Verification -- [ ] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot +- [x] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot - **Goal:** `settingsCatalogPolicy` soll die *Configuration settings* nicht nur in Backups, sondern auch in **Policy Versions** enthalten, damit **Policy Detail**, Diff/Preview/Restore auf den echten Settings basieren. - **Why:** Aktuell hydriert nur `BackupService`, aber Policy Detail/Versions zeigen weiterhin nur Base-Metadaten. @@ -610,7 +610,7 @@ ## Acceptance Criteria -- [ ]T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) +- [x] T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) - **Goal:** Settings Catalog Policies sollen im Policy/Version Detail **für Admins lesbar** sein, ohne dass wir “alle Settings kennen müssen”. - Tabelle zeigt **sprechende Bezeichnung** + **kompakte Werte** @@ -699,7 +699,7 @@ ## Acceptance Criteria - **Readable Setting name** (not a cut-off vendor string) - **Readable Value preview** (True/False/12/etc.) -- [ ] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings +- [x] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings **Goal:** Restore für `settingsCatalogPolicy` soll Settings zuverlässig anwenden können, ohne ModelValidationFailure wegen fehlender/entfernter `@odata.type`. @@ -787,7 +787,7 @@ ### Implementation for User Story 4 - [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection. - [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`. - [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`. -- [ ] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. +- [x] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. ## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2) diff --git a/specs/018-driver-updates-wufb/checklists/requirements.md b/specs/018-driver-updates-wufb/checklists/requirements.md new file mode 100644 index 0000000..2186975 --- /dev/null +++ b/specs/018-driver-updates-wufb/checklists/requirements.md @@ -0,0 +1,15 @@ +# Requirements Checklist (018) + +**Created**: 2026-01-03 +**Feature**: [spec.md](../spec.md) + +- [ ] `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). +- [ ] Sync lists and stores driver update profiles in the Policies inventory. +- [ ] Snapshot capture stores a complete payload for backups and versions. +- [ ] Restore preview is available and respects the configured restore mode. +- [ ] Restore execution applies only patchable properties and records audit logs. +- [ ] Normalized settings view is readable for admins (no raw-only UX). +- [ ] Pest tests cover sync + snapshot + restore + normalized display. +- [ ] Pint run (`./vendor/bin/pint --dirty`) on touched files. + diff --git a/specs/018-driver-updates-wufb/plan.md b/specs/018-driver-updates-wufb/plan.md new file mode 100644 index 0000000..1b26dda --- /dev/null +++ b/specs/018-driver-updates-wufb/plan.md @@ -0,0 +1,24 @@ +# Plan: Driver Updates (WUfB Add-on) (018) + +**Branch**: `feat/018-driver-updates-wufb` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md) + +## Goal +Add first-class support for Windows Driver Update profiles (`windowsDriverUpdateProfile`) across inventory, backup/version snapshots, restore (preview + execution), and normalized display. + +## Approach +1. Confirm Graph API details for driver update profiles (resource path, `@odata.type`, patchable properties, assignment endpoints). +2. Add type metadata to `config/tenantpilot.php` (category, endpoint, backup/restore mode, risk). +3. Add Graph contract entry in `config/graph_contracts.php` (resource, type family, create/update methods, assignments). +4. Ensure sync lists and stores these policies (config-driven loop) and add a targeted sync test. +5. Ensure snapshots capture the complete payload and add tests for version/backup capture. +6. Implement restore apply via contract-driven sanitization; add failure-safe behavior and tests. +7. Add a normalizer for readable UI output; add tests for normalized display. +8. Run Pint and targeted tests. + +## Decisions / Notes +- Default to contract-driven restore semantics; avoid bespoke Graph calls unless strictly required. +- If Graph rejects PATCH due to read-only fields, extend `update_strip_keys` for this type (do not loosen safety). +- Keep restore risk high; require clear preview and audit trail. + diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md new file mode 100644 index 0000000..d5315e0 --- /dev/null +++ b/specs/018-driver-updates-wufb/spec.md @@ -0,0 +1,77 @@ +# Feature Specification: Driver Updates (WUfB Add-on) (018) + +**Feature Branch**: `feat/018-driver-updates-wufb` +**Created**: 2026-01-03 +**Status**: Draft +**Priority**: P1 + +## Context +TenantPilot already covers core Windows Update for Business (WUfB) objects like: +- Update Rings (`windowsUpdateRing`) +- Feature Update Profiles (`windowsFeatureUpdateProfile`) +- Quality Update Profiles (`windowsQualityUpdateProfile`) + +This feature adds **Windows Driver Updates** coverage to the same Update Management area so driver rollout configuration can be inventoried, snapshotted, diffed, and restored safely. + +## In Scope +- New policy type: `windowsDriverUpdateProfile` +- Inventory/sync: list driver update profiles from Microsoft Graph and store them as policies. +- Snapshot capture: full snapshot of the profile payload (and assignments where supported). +- Restore: + - Preview/dry-run with diff + risk checks. + - Execution (PATCH/POST) as allowed by Graph, with audit logging. +- UI: normalized settings display (readable, admin-focused). + +## Out of Scope (v1) +- Per-driver approval workflows / driver inventory insights. +- Advanced reporting on driver compliance. +- Partial per-setting restore. + +## Graph API Assumptions (to verify) +- **Resource**: `deviceManagement/windowsDriverUpdateProfiles` +- **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile` +- **Assignments**: standard pattern with: + - list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments` + - assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign` + +## User Scenarios & Testing + +### User Story 1 — Inventory + readable view (P1) +As an admin, I can see Windows Driver Update profiles in the Policies list and view their configuration in a readable way. + +**Acceptance** +1. Driver update profiles appear in the policy inventory with the correct type and category. +2. Policy detail shows a normalized settings table (not only raw JSON). +3. Policy Versions render “Normalized settings” consistently. + +### User Story 2 — Snapshot capture (P1) +As an admin, when I capture a version or add a driver update profile to a backup set, the snapshot contains all relevant settings. + +**Acceptance** +1. Snapshot stores the full Graph payload in JSON (immutable). +2. Any non-patchable/read-only properties are still preserved in the snapshot (but not sent on restore). + +### User Story 3 — Restore preview + execution (P1) +As an admin, I can restore a driver update profile from a snapshot with a clear preview and safe execution. + +**Acceptance** +1. Preview shows what would change and blocks if risk checks fail. +2. Execution applies only patchable properties (contract-driven sanitization). +3. Restore results include Graph error details (request-id, client-request-id, path/method) on failure. + +## Requirements + +### Functional Requirements +- **FR-001**: Add `windowsDriverUpdateProfile` to `config/tenantpilot.php` with category “Update Management”. +- **FR-002**: Add Graph contract entry for `windowsDriverUpdateProfile` in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- **FR-003**: Ensure `PolicySyncService` syncs driver update profiles via config-driven type list. +- **FR-004**: Ensure `PolicySnapshotService` captures a complete payload for this type. +- **FR-005**: Ensure `RestoreService` applies snapshots using contract-driven sanitization and audit logging. +- **FR-006**: Add normalized display support for the key driver update profile fields. +- **FR-007**: Add automated Pest tests for sync + snapshot + restore preview/execution. + +### Non-Functional Requirements +- **NFR-001**: Preserve tenant isolation and least privilege. +- **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit). +- **NFR-003**: No new external services or dependencies. + diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md new file mode 100644 index 0000000..b6bcc69 --- /dev/null +++ b/specs/018-driver-updates-wufb/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Driver Updates (WUfB Add-on) (018) + +**Branch**: `feat/018-driver-updates-wufb` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create/confirm spec, plan, tasks, checklist. + +## Phase 2: Research & Design +- [ ] T002 Verify Graph resource + `@odata.type` for driver update profiles. +- [ ] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. +- [ ] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. +- [ ] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. +- [ ] T007 Add snapshot/version capture test asserting full payload is stored. +- [ ] 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. +- [ ] T010 Add normalized display test for key fields. + +## Phase 4: Implementation +- [ ] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. +- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [ ] T013 Implement any required snapshot hydration (if Graph uses subresources). +- [ ] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). +- [ ] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. + +## Phase 5: Verification +- [ ] T016 Run targeted tests. +- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/specs/023-endpoint-security-restore/checklists/requirements.md b/specs/023-endpoint-security-restore/checklists/requirements.md index 2da1d80..7984f8a 100644 --- a/specs/023-endpoint-security-restore/checklists/requirements.md +++ b/specs/023-endpoint-security-restore/checklists/requirements.md @@ -3,12 +3,11 @@ # Requirements Checklist (023) **Created**: 2026-01-03 **Feature**: [spec.md](../spec.md) -- [ ] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`. -- [ ] Restore preview validates template existence and reports missing/ambiguous templates. -- [ ] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message. -- [ ] Settings instances are validated against resolved template definitions before execution. -- [ ] Template mapping strategy is defined for cross-tenant differences (if required) and is tested. -- [ ] Restore create + update paths for Endpoint Security policies are covered by automated tests. -- [ ] Assignments mapping/application for Endpoint Security policies are covered by automated tests. -- [ ] Audit log entries exist for restore execution attempts (success and failure). - +- [x] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`. +- [x] Restore preview validates template existence and reports missing/ambiguous templates. +- [x] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message. +- [x] Settings instances are validated against resolved template definitions before execution. +- [x] Template mapping strategy is defined for cross-tenant differences (if required) and is tested. +- [x] Restore create + update paths for Endpoint Security policies are covered by automated tests. +- [x] Assignments mapping/application for Endpoint Security policies are covered by automated tests. +- [x] Audit log entries exist for restore execution attempts (success and failure). diff --git a/specs/023-endpoint-security-restore/plan.md b/specs/023-endpoint-security-restore/plan.md index c843861..8109384 100644 --- a/specs/023-endpoint-security-restore/plan.md +++ b/specs/023-endpoint-security-restore/plan.md @@ -3,6 +3,7 @@ # Plan: Endpoint Security Policy Restore (023) **Branch**: `feat/023-endpoint-security-restore` **Date**: 2026-01-03 **Input**: [spec.md](./spec.md) +**Status**: Implemented (ready to merge) ## Goal Enable full restore execution for Endpoint Security Policies (`endpointSecurityPolicy`) instead of preview-only, with defensive validation around templates and settings payloads. @@ -29,4 +30,4 @@ ## Approach ## Decisions / Notes - Assume template identifiers may differ across tenants; prefer mapping by `templateFamily` with display-name fallback when required. - Safety-first: if template resolution is ambiguous, treat as missing and block execution. - + - Incident hardening: make restore failures actionable by surfacing Graph path/method and avoid unsafe fallback endpoints. diff --git a/specs/023-endpoint-security-restore/spec.md b/specs/023-endpoint-security-restore/spec.md index 81b3fd5..c8cfe5f 100644 --- a/specs/023-endpoint-security-restore/spec.md +++ b/specs/023-endpoint-security-restore/spec.md @@ -2,13 +2,13 @@ # Feature Specification: Enable Endpoint Security Policy Restore (023) **Feature Branch**: `feat/023-endpoint-security-restore` **Created**: 2026-01-03 -**Status**: Draft +**Status**: Implemented (ready to merge) **Priority**: P1 (Quick Win) ## Context Endpoint Security Policies are already in the `tenantpilot.php` config as `endpointSecurityPolicy` with `restore => 'preview-only'`. Based on Microsoft's recommendation to use the unified `deviceManagement/configurationPolicies` endpoint (over the deprecated `intents` API for new creations), we should enable full restore for this type. -This is a **configuration-only change** with additional validation/testing, not a new policy type implementation. +This is a **restore-mode enablement** with additional validation/testing and targeted restore hardening, not a new policy type implementation. ## User Scenarios & Testing diff --git a/specs/023-endpoint-security-restore/tasks.md b/specs/023-endpoint-security-restore/tasks.md index 049479b..5142e65 100644 --- a/specs/023-endpoint-security-restore/tasks.md +++ b/specs/023-endpoint-security-restore/tasks.md @@ -8,25 +8,30 @@ ## Phase 1: Setup - [x] T001 Create spec/plan/tasks and checklist. ## Phase 2: Inventory & Design -- [ ] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services). -- [ ] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls. -- [ ] T004 Define settings instance validation rules (warning vs block) for restore preview/execution. +- [x] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services). +- [x] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls. +- [x] T004 Define settings instance validation rules (warning vs block) for restore preview/execution. ## Phase 3: Tests (TDD) -- [ ] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`. -- [ ] T006 Add feature tests for preview warnings when template is missing. -- [ ] T007 Add feature tests asserting restore execution fails gracefully when template is missing. -- [ ] T008 Add tests for settings validation failure paths (invalid/unknown settings instances). -- [ ] T009 Add feature tests asserting assignments are applied for endpoint security policies. +- [x] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`. +- [x] T006 Add feature tests for preview warnings when template is missing. +- [x] T007 Add feature tests asserting restore execution fails gracefully when template is missing. +- [x] T008 Add tests for settings validation failure paths (invalid/unknown settings instances). +- [x] T009 Add feature tests asserting assignments are applied for endpoint security policies. ## Phase 4: Implementation -- [ ] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`. -- [ ] T011 Implement template existence validation in restore preview and execution gating. -- [ ] T012 Implement settings instance validation against resolved template definitions. -- [ ] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference. -- [ ] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic. +- [x] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`. +- [x] T011 Implement template existence validation in restore preview and execution gating. +- [x] T012 Implement settings instance validation against resolved template definitions. +- [x] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference. +- [x] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic. ## Phase 5: Verification -- [ ] T015 Run targeted tests. -- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T015 Run targeted tests. +- [x] T016 Run Pint (`./vendor/bin/pint --dirty`). +## Phase 6: Hardening (Incident-driven) +- [x] T017 Default unknown policy types to `preview-only` to avoid invalid Graph endpoints. +- [x] T018 Harden endpoint resolution fallback for configuration policy types (avoid `deviceManagement/{policyType}`). +- [x] T019 Surface Graph method/path in RestoreRun Results for faster debugging. +- [x] T020 Strip non-patchable fields for `endpointSecurityIntent` PATCH (`isAssigned`, `templateId`, `isMigratingToConfigurationPolicy`). -- 2.45.2 From a7d715c89e35a22f655326a0d4575a3e8d93efec Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 00:37:10 +0100 Subject: [PATCH 2/5] feat(018): add windows driver update profiles --- .specify/spec.md | 9 ++ app/Providers/AppServiceProvider.php | 2 + .../WindowsDriverUpdateProfileNormalizer.php | 125 ++++++++++++++++++ config/graph_contracts.php | 36 +++++ config/tenantpilot.php | 10 ++ .../checklists/requirements.md | 13 +- specs/018-driver-updates-wufb/spec.md | 8 +- specs/018-driver-updates-wufb/tasks.md | 26 ++-- .../WindowsUpdateProfilesRestoreTest.php | 85 ++++++++++++ tests/Feature/PolicySyncServiceTest.php | 46 ++++++- ...ndowsDriverUpdateProfileNormalizerTest.php | 38 ++++++ 11 files changed, 374 insertions(+), 24 deletions(-) create mode 100644 app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php create mode 100644 tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php diff --git a/.specify/spec.md b/.specify/spec.md index e664e78..227841a 100644 --- a/.specify/spec.md +++ b/.specify/spec.md @@ -42,6 +42,10 @@ ## Scope name: "Quality Updates (Windows)" graph_resource: "deviceManagement/windowsQualityUpdateProfiles" + - key: windowsDriverUpdateProfile + name: "Driver Updates (Windows)" + graph_resource: "deviceManagement/windowsDriverUpdateProfiles" + - key: deviceCompliancePolicy name: "Device Compliance" graph_resource: "deviceManagement/deviceCompliancePolicies" @@ -158,6 +162,11 @@ ## Scope restore: enabled risk: high + windowsDriverUpdateProfile: + backup: full + restore: enabled + risk: high + deviceCompliancePolicy: backup: full restore: enabled diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bb90ea..b5c7c32 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\WindowsDriverUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer; @@ -49,6 +50,7 @@ public function register(): void ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, + WindowsDriverUpdateProfileNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, WindowsUpdateRingNormalizer::class, diff --git a/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php new file mode 100644 index 0000000..0bd657e --- /dev/null +++ b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php @@ -0,0 +1,125 @@ +>, settings_table?: array, warnings: array} + */ + 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 + */ + 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; + } + } +} diff --git a/config/graph_contracts.php b/config/graph_contracts.php index b6c35a4..674e824 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -296,6 +296,42 @@ 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', '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' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 1f6f205..d222293 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -64,6 +64,16 @@ 'restore' => 'enabled', 'risk' => 'high', ], + [ + 'type' => 'windowsDriverUpdateProfile', + 'label' => 'Driver Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsDriverUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', diff --git a/specs/018-driver-updates-wufb/checklists/requirements.md b/specs/018-driver-updates-wufb/checklists/requirements.md index 2186975..1a0204c 100644 --- a/specs/018-driver-updates-wufb/checklists/requirements.md +++ b/specs/018-driver-updates-wufb/checklists/requirements.md @@ -3,13 +3,12 @@ # Requirements Checklist (018) **Created**: 2026-01-03 **Feature**: [spec.md](../spec.md) -- [ ] `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). -- [ ] Sync lists and stores driver update profiles in the Policies inventory. +- [x] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). +- [x] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- [x] Sync lists and stores driver update profiles in the Policies inventory. - [ ] Snapshot capture stores a complete payload for backups and versions. - [ ] Restore preview is available and respects the configured restore mode. -- [ ] Restore execution applies only patchable properties and records audit logs. -- [ ] Normalized settings view is readable for admins (no raw-only UX). +- [x] Restore execution applies only patchable properties and records audit logs. +- [x] Normalized settings view is readable for admins (no raw-only UX). - [ ] 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. diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md index d5315e0..5ff4aaa 100644 --- a/specs/018-driver-updates-wufb/spec.md +++ b/specs/018-driver-updates-wufb/spec.md @@ -27,12 +27,15 @@ ## Out of Scope (v1) - Advanced reporting on driver compliance. - Partial per-setting restore. -## Graph API Assumptions (to verify) +## Graph API Details (confirmed) - **Resource**: `deviceManagement/windowsDriverUpdateProfiles` - **@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` - assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign` + - update/delete: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}` ## User Scenarios & Testing @@ -74,4 +77,3 @@ ### Non-Functional Requirements - **NFR-001**: Preserve tenant isolation and least privilege. - **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit). - **NFR-003**: No new external services or dependencies. - diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md index b6bcc69..f7b8e38 100644 --- a/specs/018-driver-updates-wufb/tasks.md +++ b/specs/018-driver-updates-wufb/tasks.md @@ -8,25 +8,25 @@ ## Phase 1: Setup - [x] T001 Create/confirm spec, plan, tasks, checklist. ## Phase 2: Research & Design -- [ ] T002 Verify Graph resource + `@odata.type` for driver update profiles. -- [ ] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. -- [ ] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. -- [ ] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. +- [x] T002 Verify Graph resource + `@odata.type` for driver update profiles. +- [x] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. +- [x] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. +- [x] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. ## 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. - [ ] 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. -- [ ] T010 Add normalized display test for key fields. +- [x] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. +- [x] T010 Add normalized display test for key fields. ## Phase 4: Implementation -- [ ] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. -- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [x] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. +- [x] T012 Add Graph contract entry in `config/graph_contracts.php`. - [ ] T013 Implement any required snapshot hydration (if Graph uses subresources). -- [ ] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). -- [ ] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. +- [x] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). +- [x] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. ## Phase 5: Verification -- [ ] T016 Run targeted tests. -- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T016 Run targeted tests. +- [x] T017 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index fcbf824..4ef31ec 100644 --- a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -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('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'); +}); diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php index 60beb81..7c056a3 100644 --- a/tests/Feature/PolicySyncServiceTest.php +++ b/tests/Feature/PolicySyncServiceTest.php @@ -62,7 +62,7 @@ $supported = config('tenantpilot.supported_policy_types'); $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) ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); @@ -75,6 +75,50 @@ expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) ->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 () { diff --git a/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php new file mode 100644 index 0000000..226e594 --- /dev/null +++ b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php @@ -0,0 +1,38 @@ + '#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'); +}); -- 2.45.2 From 720f13a866c4994f1d0c277e97e84529c2636eb3 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 00:38:14 +0100 Subject: [PATCH 3/5] spec(018): mark in progress --- specs/018-driver-updates-wufb/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md index 5ff4aaa..ef780d2 100644 --- a/specs/018-driver-updates-wufb/spec.md +++ b/specs/018-driver-updates-wufb/spec.md @@ -2,7 +2,7 @@ # Feature Specification: Driver Updates (WUfB Add-on) (018) **Feature Branch**: `feat/018-driver-updates-wufb` **Created**: 2026-01-03 -**Status**: Draft +**Status**: In Progress **Priority**: P1 ## Context -- 2.45.2 From b94377db8e14dae59a2755d24a9ad6baf49ba7db Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 00:42:41 +0100 Subject: [PATCH 4/5] test(018): cover snapshot + preview --- .../checklists/requirements.md | 6 +- specs/018-driver-updates-wufb/tasks.md | 4 +- tests/Feature/Filament/RestorePreviewTest.php | 71 +++++++++++++++++++ tests/Unit/PolicySnapshotServiceTest.php | 56 +++++++++++++++ 4 files changed, 132 insertions(+), 5 deletions(-) diff --git a/specs/018-driver-updates-wufb/checklists/requirements.md b/specs/018-driver-updates-wufb/checklists/requirements.md index 1a0204c..d6c149e 100644 --- a/specs/018-driver-updates-wufb/checklists/requirements.md +++ b/specs/018-driver-updates-wufb/checklists/requirements.md @@ -6,9 +6,9 @@ # Requirements Checklist (018) - [x] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). - [x] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). - [x] Sync lists and stores driver update profiles in the Policies inventory. -- [ ] Snapshot capture stores a complete payload for backups and versions. -- [ ] Restore preview is available and respects the configured restore mode. +- [x] Snapshot capture stores a complete payload for backups and versions. +- [x] Restore preview is available and respects the configured restore mode. - [x] Restore execution applies only patchable properties and records audit logs. - [x] Normalized settings view is readable for admins (no raw-only UX). -- [ ] Pest tests cover sync + snapshot + restore + normalized display. +- [x] Pest tests cover sync + snapshot + restore + normalized display. - [x] Pint run (`./vendor/bin/pint --dirty`) on touched files. diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md index f7b8e38..d1e0a9a 100644 --- a/specs/018-driver-updates-wufb/tasks.md +++ b/specs/018-driver-updates-wufb/tasks.md @@ -15,8 +15,8 @@ ## Phase 2: Research & Design ## Phase 3: Tests (TDD) - [x] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. -- [ ] T007 Add snapshot/version capture test asserting full payload is stored. -- [ ] T008 Add restore preview test for this type (entries + restore_mode shown). +- [x] T007 Add snapshot/version capture test asserting full payload is stored. +- [x] T008 Add restore preview test for this type (entries + restore_mode shown). - [x] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. - [x] T010 Add normalized display test for key fields. diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index a4ccb0a..929e83e 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -104,6 +104,77 @@ public function request(string $method, string $path, array $options = []): Grap expect($policyPreview['action'])->toBe('update'); }); +test('restore preview shows enabled restore mode for windows driver update profiles', function () { + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + 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 + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-driver-preview', + 'name' => 'Tenant Preview', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'wdp-1', + 'policy_type' => 'windowsDriverUpdateProfile', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + expect($preview)->toHaveCount(1); + + $policyPreview = $preview[0] ?? []; + expect($policyPreview['policy_type'] ?? null)->toBe('windowsDriverUpdateProfile'); + expect($policyPreview['action'] ?? null)->toBe('create'); + expect($policyPreview['restore_mode'] ?? null)->toBe('enabled'); +}); + test('restore preview warns about missing compliance notification templates', function () { app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface { diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9367f93..9256adf 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -41,6 +41,27 @@ public function getPolicy(string $policyType, string $policyId, array $options = ]); } + if ($policyType === 'windowsDriverUpdateProfile') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'roleScopeTagIds' => ['0'], + 'inventorySyncStatus' => [ + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfileInventorySyncStatus', + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + ], + ]); + } + return new GraphResponse(success: true, data: [ 'payload' => [ 'id' => $policyId, @@ -271,6 +292,41 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); +it('captures windows driver update profile snapshots with full payload', function () { + $client = new PolicySnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-driver', + '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' => 'wdp-123', + 'policy_type' => 'windowsDriverUpdateProfile', + 'display_name' => 'Driver Updates A', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload']['approvalType'] ?? null)->toBe('automatic'); + expect($result['payload']['deploymentDeferralInDays'] ?? null)->toBe(7); + expect($result['payload']['deviceReporting'] ?? null)->toBe(12); + expect($result['payload']['newUpdates'] ?? null)->toBe(3); + expect($result['payload']['inventorySyncStatus']['driverInventorySyncState'] ?? null)->toBe('success'); + + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('windowsDriverUpdateProfile'); + expect($client->requests[0][2])->toBe('wdp-123'); +}); + test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () { $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); $client->shouldReceive('getPolicy') -- 2.45.2 From b5743b9fbba174252dd56ac400a30812281f1d43 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 4 Jan 2026 00:43:42 +0100 Subject: [PATCH 5/5] spec(018): mark implemented --- specs/018-driver-updates-wufb/spec.md | 2 +- specs/018-driver-updates-wufb/tasks.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md index ef780d2..5ef2dcc 100644 --- a/specs/018-driver-updates-wufb/spec.md +++ b/specs/018-driver-updates-wufb/spec.md @@ -2,7 +2,7 @@ # Feature Specification: Driver Updates (WUfB Add-on) (018) **Feature Branch**: `feat/018-driver-updates-wufb` **Created**: 2026-01-03 -**Status**: In Progress +**Status**: Implemented **Priority**: P1 ## Context diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md index d1e0a9a..19bf842 100644 --- a/specs/018-driver-updates-wufb/tasks.md +++ b/specs/018-driver-updates-wufb/tasks.md @@ -23,7 +23,7 @@ ## Phase 3: Tests (TDD) ## Phase 4: Implementation - [x] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. - [x] T012 Add Graph contract entry in `config/graph_contracts.php`. -- [ ] T013 Implement any required snapshot hydration (if Graph uses subresources). +- [x] T013 Implement any required snapshot hydration (if Graph uses subresources). - [x] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). - [x] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. -- 2.45.2