From ba468de486a9df3f56e88742103ce2ef6e20759f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 27 Dec 2025 01:22:45 +0100 Subject: [PATCH 01/14] spec: add 007 device config & compliance spec --- specs/007-device-config-compliance/plan.md | 79 +++++++++++++++++++++ specs/007-device-config-compliance/spec.md | 78 ++++++++++++++++++++ specs/007-device-config-compliance/tasks.md | 74 +++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 specs/007-device-config-compliance/plan.md create mode 100644 specs/007-device-config-compliance/spec.md create mode 100644 specs/007-device-config-compliance/tasks.md diff --git a/specs/007-device-config-compliance/plan.md b/specs/007-device-config-compliance/plan.md new file mode 100644 index 0000000..a8af39f --- /dev/null +++ b/specs/007-device-config-compliance/plan.md @@ -0,0 +1,79 @@ +# Implementation Plan: Device Configuration and Compliance Coverage + +**Branch**: `007-device-config-compliance` | **Date**: 2025-12-26 | **Spec**: ./spec.md +**Input**: Feature specification from `/specs/007-device-config-compliance/spec.md` + +## Summary + +Expand backup and restore coverage for device configuration, compliance, scripts, and update rings. This plan focuses on policy type coverage, assignment capture, and safe restore behavior using existing foundation mappings and assignment logic. + +Phase outputs: +- Phase 0 research: n/a (no new research artifact yet) +- Phase 1 design: n/a (no new data model artifact yet) + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3, Microsoft Graph (custom client abstraction) +**Storage**: PostgreSQL (JSONB payload storage for snapshots) +**Testing**: Pest v4 + PHPUnit 12 +**Target Platform**: Docker/Sail locally; container deploy via Dokploy +**Project Type**: Web application (Laravel backend + Filament admin UI) +**Performance Goals**: Restore preview for 100 selected items in under 2 minutes +**Constraints**: Restore must be defensive (no deletions); assignments only applied with valid mapping; audit logs required +**Scale/Scope**: Tenants with mixed configuration and compliance policies, including scripts and update rings + +## Constitution Check + +The constitution at `.specify/memory/constitution.md` is currently an unfilled template. For this feature, adopt the repo rules as gates: + +- Sail-first local dev/test commands. +- Spec gate: code changes must be accompanied by `specs/007-device-config-compliance/` updates. +- Tests required for behavior changes (Pest). +- Restore safety: never delete; skip unsafe assignments; record reasons. +- Auditability: backup and restore outcomes are logged per tenant. + +## Project Structure + +### Documentation (this feature) + +```text +specs/007-device-config-compliance/ +├── spec.md +├── plan.md +└── tasks.md +``` + +### Source Code (expected touch points) + +```text +app/ +├── Filament/ +│ └── Resources/ +├── Models/ +│ ├── BackupItem.php +│ ├── Policy.php +│ └── PolicyVersion.php +├── Services/ +│ ├── Graph/ +│ └── Intune/ +└── Jobs/ + +config/ +├── graph_contracts.php +├── intune_permissions.php +└── tenantpilot.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Extend existing services (PolicySnapshotService, PolicyCaptureOrchestrator, RestoreService) and Filament resources, adding only targeted helpers where needed. + +## Complexity Tracking + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| n/a | n/a | n/a | + diff --git a/specs/007-device-config-compliance/spec.md b/specs/007-device-config-compliance/spec.md new file mode 100644 index 0000000..59f7cb9 --- /dev/null +++ b/specs/007-device-config-compliance/spec.md @@ -0,0 +1,78 @@ +# Feature Specification: Device Configuration and Compliance Coverage + +**Feature Branch**: `007-device-config-compliance` +**Created**: 2025-12-26 +**Status**: Draft +**Input**: Workload list for Intune backup and restore coverage (MVP vs full scope). + +## Program Scope Reference (MVP vs Full) + +### MVP Scope (Phase 1) +- Device configuration and compliance: administrative templates; settings catalog policies; device configurations (including custom OMA-URI); device compliance policies; assignments. +- Scripts and remediations: PowerShell scripts (Windows); macOS shell scripts (where supported); proactive remediations and assignments. +- Enrollment and Autopilot: Autopilot deployment profiles and assignments; Enrollment Status Page (ESP) if used. +- Update management (Windows): software update rings and assignments. +- Endpoint security: endpoint security configurations (antivirus, firewall, disk encryption, EDR, ASR, account protection) and assignments. +- Tenant administration foundations: assignment filters; scope tags; notification message templates. + +### Full Scope (Phase 2+) +- Compliance actions and notifications: actions for noncompliance; compliance notifications and templates. +- Apps and app management: client apps; app protection policies; app configuration policies; assignments; supersedence metadata. +- Enrollment: enrollment restrictions; enrollment notifications; terms and conditions; ADE tokens and profiles. +- Update management: feature update policies; quality update policies; driver update policies; expedite/hotpatch policies. +- Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies. +- Tenant administration: device cleanup rules; RBAC roles and role assignments. +- Connectors and tokens (metadata-only): APNs; VPP tokens; managed Google Play; certificate connectors; remote help settings. + +## Overview +Expand backup and restore coverage for device configuration and compliance workloads, including scripts and remediations. This feature focuses on policy types that are already core to DR and rollback, and builds on existing foundations and assignment mapping capabilities. + +## User Scenarios and Testing (mandatory) + +### User Story 1 - Backup and Restore Core Configuration Policies (Priority: P1) +As an admin, I want to back up and restore device configuration and compliance policies with their assignments and scope tags, so that a restore reproduces targeting accurately. + +**Independent Test**: Select at least one settings catalog policy, one device configuration policy (including an OMA-URI policy), and one device compliance policy. Create a backup with assignments and scope tags enabled. Restore into a tenant with different group IDs and verify assignments are mapped or skipped with clear reasons. + +**Acceptance Scenarios**: +1. Given policies with assignments and scope tags, when a backup is captured, then assignments and scope tag metadata are stored alongside the snapshot. +2. Given a restore run with group mapping, when policies are restored, then assignments are applied using mapped group IDs and assignment filters. +3. Given missing mappings, when restore executes, then assignments are skipped and a human readable reason is recorded. + +### User Story 2 - Compliance Actions and Notifications (Priority: P2) +As an admin, I want actions for noncompliance and compliance notification templates to be captured and restored, so that compliance workflows remain intact after restore. + +**Independent Test**: Create a compliance policy with scheduled actions and a notification template. Capture a backup including foundations. Restore into a tenant without that template and verify the template is created and referenced correctly. + +**Acceptance Scenarios**: +1. Given a compliance policy referencing a notification template, when restore executes, then the template is restored first and the policy references the mapped template ID. +2. Given a missing template and no mapping, when restore executes, then the policy is restored without that action and a skip reason is recorded. + +### User Story 3 - Scripts and Remediations (Priority: P3) +As an admin, I want scripts and remediations to be captured and restored with assignments, so that endpoint automation is preserved. + +**Independent Test**: Capture a PowerShell script and a proactive remediation with assignments. Restore into a test tenant and verify assignments are applied safely. + +**Acceptance Scenarios**: +1. Given a script policy with assignments, when restore executes, then the script is recreated or updated and assignments are applied. +2. Given a remediation with missing assignment filter mapping, when restore executes, then the assignment is skipped and the remediation is still restored. + +## Requirements (mandatory) + +### Functional Requirements +- **FR-007.1**: System MUST support backup and restore for administrative templates, settings catalog policies, device configurations (including OMA-URI), and device compliance policies. +- **FR-007.2**: System MUST capture assignments and scope tags when the backup flags are enabled, using the existing capture orchestrator. +- **FR-007.3**: System MUST handle compliance actions and notification templates by restoring templates first and mapping references in policies. +- **FR-007.4**: System MUST restore scripts and remediations with assignments, applying foundation mappings and group mappings where available. +- **FR-007.5**: System MUST keep Conditional Access restore preview-only until identity dependency mapping is supported. +- **FR-007.6**: System MUST record audit logs for backup and restore actions, including skipped assignments and template mapping outcomes. + +### Non-Goals +- No support for app workloads in this feature (tracked separately). +- No connector or token restore (metadata-only handled in a later phase). + +## Success Criteria (mandatory) +- **SC-007.1**: For a backup containing at least 10 mixed configuration/compliance items, restore completes with 100% of items in Applied, Partial, or Skipped with reason (no silent failures). +- **SC-007.2**: At least 95% of assignments in a mixed restore are either applied successfully or explicitly skipped with a recorded reason. +- **SC-007.3**: Restore preview for 100 selected items completes in under 2 minutes in a typical admin environment. + diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md new file mode 100644 index 0000000..e5c583a --- /dev/null +++ b/specs/007-device-config-compliance/tasks.md @@ -0,0 +1,74 @@ +# Tasks: Device Configuration and Compliance Coverage (007) + +**Branch**: `feat/007-device-config-compliance` | **Date**: 2025-12-26 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Task Format + +- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete +- **Task ID**: Sequential T001, T002, T003... +- **[P] marker**: Task can run in parallel (different files, no blocking dependencies) +- **[Story] label**: User story tag (US1, US2, US3...) +- **File path**: Always include exact file path in description + +## Phase 1: Policy Types, Contracts, Permissions + +**Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage. + +- [ ] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk). +- [ ] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`. +- [ ] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`. +- [ ] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. + +**Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry. + +--- + +## Phase 2: Snapshot Capture and Metadata + +**Purpose**: Ensure snapshots, assignments, and scope tags are captured for the new workloads. + +- [ ] T005 Update `app/Services/Intune/PolicySnapshotService.php` to fetch and hydrate the new policy types correctly (filters, select fields). +- [ ] T006 Extend `app/Services/Intune/PolicyCaptureOrchestrator.php` to capture assignments and scope tags for the new types with existing resolvers. +- [ ] T007 Update `app/Services/Intune/BackupService.php` to capture snapshots for the new types and propagate warnings. +- [ ] T008 Add or extend normalization support in `app/Services/Intune/PolicyNormalizer.php` for the new policy types. + +**Checkpoint**: Backups include snapshots and metadata for configuration/compliance policies. + +--- + +## Phase 3: Restore Logic and Mapping + +**Purpose**: Restore new policy types safely using assignment and foundation mappings. + +- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. +- [ ] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. +- [ ] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. +- [ ] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. + +**Checkpoint**: Restore applies policies and assignments or skips with clear reasons. + +--- + +## Phase 4: Admin UX + +**Purpose**: Surface restore and compliance details clearly in the UI. + +- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings. +- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons. + +**Checkpoint**: Admins can see compliance related mapping results in preview and results. + +--- + +## Phase 5: Tests and Verification + +**Purpose**: Cover new workloads with Pest tests and verify formatting. + +- [ ] T015 Add unit tests for snapshot and normalization coverage in `tests/Unit/PolicySnapshotServiceTest.php` and `tests/Unit/PolicyNormalizerTest.php`. +- [ ] T016 Add feature tests for backup and restore flows in `tests/Feature/Filament/RestorePreviewTest.php` and `tests/Feature/Filament/RestoreExecutionTest.php`. +- [ ] T017 Run tests: `./vendor/bin/sail artisan test tests/Unit/PolicySnapshotServiceTest.php tests/Unit/PolicyNormalizerTest.php tests/Feature/Filament/RestorePreviewTest.php tests/Feature/Filament/RestoreExecutionTest.php` +- [ ] T018 Run Pint: `./vendor/bin/pint --dirty` + +**Checkpoint**: Tests pass and formatting is clean. + -- 2.45.2 From 3ff79a2baaeb3a62f1f53750b64bc8815ee86ca6 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 27 Dec 2025 22:32:51 +0100 Subject: [PATCH 02/14] wip: policy normalizers and settings catalog --- app/Filament/Resources/PolicyResource.php | 133 ++- app/Jobs/FetchAssignmentsJob.php | 15 +- app/Providers/AppServiceProvider.php | 12 + app/Services/AssignmentBackupService.php | 4 +- app/Services/Graph/AssignmentFetcher.php | 54 +- .../Intune/CompliancePolicyNormalizer.php | 296 ++++++ .../Intune/DefaultPolicyNormalizer.php | 918 ++++++++++++++++++ .../DeviceConfigurationPolicyNormalizer.php | 126 +++ .../Intune/PolicyCaptureOrchestrator.php | 4 +- app/Services/Intune/PolicyNormalizer.php | 911 +---------------- app/Services/Intune/PolicySyncService.php | 3 + app/Services/Intune/PolicyTypeNormalizer.php | 18 + app/Services/Intune/RestoreService.php | 329 ++++++- .../SettingsCatalogPolicyNormalizer.php | 31 + app/Services/Intune/VersionService.php | 2 +- .../Concerns/InteractsWithODataTypes.php | 16 + config/graph_contracts.php | 103 ++ config/intune_permissions.php | 10 +- config/tenantpilot.php | 44 +- .../entries/restore-preview.blade.php | 6 + .../entries/restore-results.blade.php | 45 + specs/007-device-config-compliance/spec.md | 2 +- specs/007-device-config-compliance/tasks.md | 5 + .../PolicyViewSettingsCatalogReadableTest.php | 6 +- .../Feature/Filament/RestoreExecutionTest.php | 100 ++ tests/Feature/Filament/RestorePreviewTest.php | 86 ++ tests/Unit/AssignmentFetcherTest.php | 22 +- tests/Unit/CompliancePolicyNormalizerTest.php | 36 + ...eviceConfigurationPolicyNormalizerTest.php | 39 + tests/Unit/PolicyNormalizerRoutingTest.php | 44 + .../SettingsCatalogPolicyNormalizerTest.php | 33 + 31 files changed, 2517 insertions(+), 936 deletions(-) create mode 100644 app/Services/Intune/CompliancePolicyNormalizer.php create mode 100644 app/Services/Intune/DefaultPolicyNormalizer.php create mode 100644 app/Services/Intune/DeviceConfigurationPolicyNormalizer.php create mode 100644 app/Services/Intune/PolicyTypeNormalizer.php create mode 100644 app/Services/Intune/SettingsCatalogPolicyNormalizer.php create mode 100644 tests/Unit/CompliancePolicyNormalizerTest.php create mode 100644 tests/Unit/DeviceConfigurationPolicyNormalizerTest.php create mode 100644 tests/Unit/PolicyNormalizerRoutingTest.php create mode 100644 tests/Unit/SettingsCatalogPolicyNormalizerTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index b2d2ed5..bea7788 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -62,7 +62,7 @@ public static function infolist(Schema $schema): Schema ->columns(2) ->columnSpanFull(), - // For Settings Catalog policies: Tabs with Settings table + JSON viewer + // Tabbed content (General / Settings / JSON) Tabs::make('policy_content') ->activeTab(1) ->persistTabInQueryString() @@ -74,10 +74,7 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.infolists.entries.policy-general') ->state(function (Policy $record) { - $normalized = static::normalizedPolicyState($record); - $split = static::splitGeneralBlock($normalized); - - return $split['general']; + return static::generalOverviewState($record); }), ]) ->visible(fn (Policy $record) => $record->versions()->exists()), @@ -88,12 +85,9 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.infolists.entries.normalized-settings') ->state(function (Policy $record) { - $normalized = static::normalizedPolicyState($record); - $split = static::splitGeneralBlock($normalized); - - return $split['normalized']; + return static::settingsTabState($record); }) - ->visible(fn (Policy $record) => $record->policy_type === 'settingsCatalogPolicy' && + ->visible(fn (Policy $record) => static::hasSettingsTable($record) && $record->versions()->exists() ), @@ -101,12 +95,9 @@ public static function infolist(Schema $schema): Schema ->label('') ->view('filament.infolists.entries.policy-settings-standard') ->state(function (Policy $record) { - $normalized = static::normalizedPolicyState($record); - $split = static::splitGeneralBlock($normalized); - - return $split['normalized']; + return static::settingsTabState($record); }) - ->visible(fn (Policy $record) => $record->policy_type !== 'settingsCatalogPolicy' && + ->visible(fn (Policy $record) => ! static::hasSettingsTable($record) && $record->versions()->exists() ), @@ -144,12 +135,9 @@ public static function infolist(Schema $schema): Schema ->visible(fn (Policy $record) => $record->versions()->exists()), ]) ->columnSpanFull() - ->visible(function (Policy $record) { - return str_contains(strtolower($record->policy_type ?? ''), 'settings') || - str_contains(strtolower($record->policy_type ?? ''), 'catalog'); - }), + ->visible(fn (Policy $record) => static::usesTabbedLayout($record)), - // For non-Settings Catalog policies: Simple sections without tabs + // Legacy layout (kept for fallback if tabs are disabled) Section::make('Settings') ->schema([ ViewEntry::make('settings') @@ -170,9 +158,7 @@ public static function infolist(Schema $schema): Schema ]) ->columnSpanFull() ->visible(function (Policy $record) { - // Show simple settings section for non-Settings Catalog policies - return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') && - ! str_contains(strtolower($record->policy_type ?? ''), 'catalog'); + return ! static::usesTabbedLayout($record); }), Section::make('Policy Snapshot (JSON)') @@ -205,9 +191,7 @@ public static function infolist(Schema $schema): Schema ->description('Raw JSON configuration from Microsoft Graph API') ->columnSpanFull() ->visible(function (Policy $record) { - // Show standalone JSON section only for non-Settings Catalog policies - return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') && - ! str_contains(strtolower($record->policy_type ?? ''), 'catalog'); + return ! static::usesTabbedLayout($record); }), ]); } @@ -690,4 +674,101 @@ private static function typeMeta(?string $type): array return collect(config('tenantpilot.supported_policy_types', [])) ->firstWhere('type', $type) ?? []; } + + private static function usesTabbedLayout(Policy $record): bool + { + return true; + } + + private static function hasSettingsTable(Policy $record): bool + { + $normalized = static::normalizedPolicyState($record); + $rows = $normalized['settings_table']['rows'] ?? []; + + return is_array($rows) && $rows !== []; + } + + /** + * @return array{entries: array} + */ + private static function generalOverviewState(Policy $record): array + { + $snapshot = static::latestSnapshot($record); + $entries = []; + + $name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name; + if (is_string($name) && $name !== '') { + $entries[] = ['key' => 'Name', 'value' => $name]; + } + + $platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform; + if (is_string($platforms) && $platforms !== '') { + $entries[] = ['key' => 'Platforms', 'value' => $platforms]; + } elseif (is_array($platforms) && $platforms !== []) { + $entries[] = ['key' => 'Platforms', 'value' => $platforms]; + } + + $technologies = $snapshot['technologies'] ?? null; + if (is_string($technologies) && $technologies !== '') { + $entries[] = ['key' => 'Technologies', 'value' => $technologies]; + } elseif (is_array($technologies) && $technologies !== []) { + $entries[] = ['key' => 'Technologies', 'value' => $technologies]; + } + + if (array_key_exists('templateReference', $snapshot)) { + $entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']]; + } + + $settingCount = $snapshot['settingCount'] + ?? $snapshot['settingsCount'] + ?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null); + + if (is_int($settingCount) || is_numeric($settingCount)) { + $entries[] = ['key' => 'Setting Count', 'value' => $settingCount]; + } + + $version = $snapshot['version'] ?? null; + if (is_string($version) && $version !== '') { + $entries[] = ['key' => 'Version', 'value' => $version]; + } elseif (is_numeric($version)) { + $entries[] = ['key' => 'Version', 'value' => $version]; + } + + $lastModified = $snapshot['lastModifiedDateTime'] ?? null; + if (is_string($lastModified) && $lastModified !== '') { + $entries[] = ['key' => 'Last Modified', 'value' => $lastModified]; + } + + $createdAt = $snapshot['createdDateTime'] ?? null; + if (is_string($createdAt) && $createdAt !== '') { + $entries[] = ['key' => 'Created', 'value' => $createdAt]; + } + + $description = $snapshot['description'] ?? null; + if (is_string($description) && $description !== '') { + $entries[] = ['key' => 'Description', 'value' => $description]; + } + + return [ + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + private static function settingsTabState(Policy $record): array + { + $normalized = static::normalizedPolicyState($record); + $rows = $normalized['settings_table']['rows'] ?? []; + $hasSettingsTable = is_array($rows) && $rows !== []; + + if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { + $split = static::splitGeneralBlock($normalized); + + return $split['normalized']; + } + + return $normalized; + } } diff --git a/app/Jobs/FetchAssignmentsJob.php b/app/Jobs/FetchAssignmentsJob.php index 7bb8d89..a492a4a 100644 --- a/app/Jobs/FetchAssignmentsJob.php +++ b/app/Jobs/FetchAssignmentsJob.php @@ -51,6 +51,16 @@ public function handle(AssignmentBackupService $assignmentBackupService): void return; } + $tenant = $backupItem->tenant; + + if ($tenant === null) { + Log::warning('FetchAssignmentsJob: Tenant not found for BackupItem', [ + 'backup_item_id' => $this->backupItemId, + ]); + + return; + } + // Only process Settings Catalog policies if ($backupItem->policy_type !== 'settingsCatalogPolicy') { Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [ @@ -63,8 +73,9 @@ public function handle(AssignmentBackupService $assignmentBackupService): void $assignmentBackupService->enrichWithAssignments( backupItem: $backupItem, - tenantId: $this->tenantExternalId, - policyId: $this->policyExternalId, + tenant: $tenant, + policyType: $backupItem->policy_type, + policyId: $backupItem->policy_identifier, policyPayload: $this->policyPayload, includeAssignments: true ); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d691c7b..3226a0e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,9 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; +use App\Services\Intune\CompliancePolicyNormalizer; +use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\SettingsCatalogPolicyNormalizer; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -27,6 +30,15 @@ public function register(): void return $app->make(NullGraphClient::class); }); + + $this->app->tag( + [ + CompliancePolicyNormalizer::class, + DeviceConfigurationPolicyNormalizer::class, + SettingsCatalogPolicyNormalizer::class, + ], + 'policy-type-normalizers' + ); } /** diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index f94c697..0c12fa3 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -24,6 +24,7 @@ public function __construct( * * @param BackupItem $backupItem The backup item to enrich * @param Tenant $tenant Tenant model with credentials + * @param string $policyType Policy type key (e.g. deviceConfiguration) * @param string $policyId Policy ID (external_id from Graph) * @param array $policyPayload Full policy payload from Graph * @param bool $includeAssignments Whether to fetch and include assignments @@ -32,6 +33,7 @@ public function __construct( public function enrichWithAssignments( BackupItem $backupItem, Tenant $tenant, + string $policyType, string $policyId, array $policyPayload, bool $includeAssignments = false @@ -58,7 +60,7 @@ public function enrichWithAssignments( // Fetch assignments from Graph API $graphOptions = $tenant->graphOptions(); $tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id; - $assignments = $this->assignmentFetcher->fetch($tenantId, $policyId, $graphOptions); + $assignments = $this->assignmentFetcher->fetch($policyType, $tenantId, $policyId, $graphOptions); if (empty($assignments)) { // No assignments or fetch failed diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 53d3ec6..415881d 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -8,27 +8,32 @@ class AssignmentFetcher { public function __construct( private readonly MicrosoftGraphClient $graphClient, + private readonly GraphContractRegistry $contracts, ) {} /** * Fetch policy assignments with fallback strategy. * - * Primary: GET /deviceManagement/configurationPolicies/{id}/assignments - * Fallback: GET /deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}' + * Primary: GET {assignments_list_path} + * Fallback: GET {resource}?$expand=assignments&$filter=id eq '{id}' * * @return array Returns assignment array or empty array on failure */ - public function fetch(string $tenantId, string $policyId, array $options = []): array + public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array { try { + $contract = $this->contracts->get($policyType); + $listPathTemplate = $contract['assignments_list_path'] ?? null; + $resource = $contract['resource'] ?? null; $requestOptions = array_merge($options, ['tenant' => $tenantId]); // Try primary endpoint - $assignments = $this->fetchPrimary($policyId, $requestOptions); + $assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions); if (! empty($assignments)) { Log::debug('Fetched assignments via primary endpoint', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, 'count' => count($assignments), ]); @@ -39,14 +44,26 @@ public function fetch(string $tenantId, string $policyId, array $options = []): // Try fallback with $expand Log::debug('Primary endpoint returned empty, trying fallback', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, ]); - $assignments = $this->fetchWithExpand($policyId, $requestOptions); + if (! is_string($resource) || $resource === '') { + Log::debug('Assignments resource not configured for policy type', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + return []; + } + + $assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions); if (! empty($assignments)) { Log::debug('Fetched assignments via fallback endpoint', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, 'count' => count($assignments), ]); @@ -57,6 +74,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []): // Both methods returned empty Log::debug('No assignments found for policy', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, ]); @@ -64,6 +82,7 @@ public function fetch(string $tenantId, string $policyId, array $options = []): } catch (GraphException $e) { Log::warning('Failed to fetch assignments', [ 'tenant_id' => $tenantId, + 'policy_type' => $policyType, 'policy_id' => $policyId, 'error' => $e->getMessage(), 'context' => $e->context, @@ -76,9 +95,17 @@ public function fetch(string $tenantId, string $policyId, array $options = []): /** * Fetch assignments using primary endpoint. */ - private function fetchPrimary(string $policyId, array $options): array + private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array { - $path = "/deviceManagement/configurationPolicies/{$policyId}/assignments"; + if (! is_string($listPathTemplate) || $listPathTemplate === '') { + return []; + } + + $path = $this->resolvePath($listPathTemplate, $policyId); + + if ($path === null) { + return []; + } $response = $this->graphClient->request('GET', $path, $options); @@ -88,9 +115,9 @@ private function fetchPrimary(string $policyId, array $options): array /** * Fetch assignments using $expand fallback. */ - private function fetchWithExpand(string $policyId, array $options): array + private function fetchWithExpand(string $resource, string $policyId, array $options): array { - $path = '/deviceManagement/configurationPolicies'; + $path = $resource; $params = [ '$expand' => 'assignments', '$filter' => "id eq '{$policyId}'", @@ -108,4 +135,13 @@ private function fetchWithExpand(string $policyId, array $options): array return $policies[0]['assignments'] ?? []; } + + private function resolvePath(string $template, string $policyId): ?string + { + if ($template === '') { + return null; + } + + return str_replace('{id}', urlencode($policyId), $template); + } } diff --git a/app/Services/Intune/CompliancePolicyNormalizer.php b/app/Services/Intune/CompliancePolicyNormalizer.php new file mode 100644 index 0000000..be66042 --- /dev/null +++ b/app/Services/Intune/CompliancePolicyNormalizer.php @@ -0,0 +1,296 @@ +>, 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; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + foreach ($this->buildComplianceBlocks($snapshot) as $block) { + $normalized['settings'][] = $block; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } + + /** + * @return array> + */ + private function buildComplianceBlocks(array $snapshot): array + { + $blocks = []; + $groups = $this->groupedFields(); + $usedKeys = []; + + foreach ($groups as $title => $group) { + $rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []); + + if ($rows === []) { + continue; + } + + $blocks[] = [ + 'type' => 'table', + 'title' => $title, + 'rows' => $rows, + ]; + + $usedKeys = array_merge($usedKeys, $group['keys']); + } + + $additionalRows = $this->buildAdditionalRows($snapshot, $usedKeys); + + if ($additionalRows !== []) { + $blocks[] = [ + 'type' => 'table', + 'title' => 'Additional Settings', + 'rows' => $additionalRows, + ]; + } + + return $blocks; + } + + /** + * @return array{keys: array, labels?: array} + */ + private function groupedFields(): array + { + return [ + 'Password & Access' => [ + 'keys' => [ + 'passwordRequired', + 'passwordRequiredType', + 'passwordBlockSimple', + 'passwordMinimumLength', + 'passwordMinimumCharacterSetCount', + 'passwordExpirationDays', + 'passwordMinutesOfInactivityBeforeLock', + 'passwordPreviousPasswordBlockCount', + 'passwordRequiredToUnlockFromIdle', + ], + 'labels' => [ + 'passwordRequired' => 'Password required', + 'passwordRequiredType' => 'Password required type', + 'passwordBlockSimple' => 'Block simple passwords', + 'passwordMinimumLength' => 'Password minimum length', + 'passwordMinimumCharacterSetCount' => 'Password minimum character set count', + 'passwordExpirationDays' => 'Password expiration days', + 'passwordMinutesOfInactivityBeforeLock' => 'Password idle lock (minutes)', + 'passwordPreviousPasswordBlockCount' => 'Password history count', + 'passwordRequiredToUnlockFromIdle' => 'Password required to unlock from idle', + ], + ], + 'Defender & Threat Protection' => [ + 'keys' => [ + 'defenderEnabled', + 'defenderVersion', + 'antivirusRequired', + 'antiSpywareRequired', + 'rtpEnabled', + 'signatureOutOfDate', + 'deviceThreatProtectionEnabled', + 'deviceThreatProtectionRequiredSecurityLevel', + 'requireHealthyDeviceReport', + ], + 'labels' => [ + 'defenderEnabled' => 'Microsoft Defender enabled', + 'defenderVersion' => 'Defender version', + 'antivirusRequired' => 'Antivirus required', + 'antiSpywareRequired' => 'Anti-spyware required', + 'rtpEnabled' => 'Real-time protection enabled', + 'signatureOutOfDate' => 'Signature out of date (days)', + 'deviceThreatProtectionEnabled' => 'Device threat protection enabled', + 'deviceThreatProtectionRequiredSecurityLevel' => 'Threat protection required level', + 'requireHealthyDeviceReport' => 'Require healthy device report', + ], + ], + 'Encryption & Integrity' => [ + 'keys' => [ + 'bitLockerEnabled', + 'storageRequireEncryption', + 'tpmRequired', + 'secureBootEnabled', + 'codeIntegrityEnabled', + 'memoryIntegrityEnabled', + 'kernelDmaProtectionEnabled', + 'firmwareProtectionEnabled', + 'virtualizationBasedSecurityEnabled', + 'earlyLaunchAntiMalwareDriverEnabled', + ], + 'labels' => [ + 'bitLockerEnabled' => 'BitLocker required', + 'storageRequireEncryption' => 'Storage encryption required', + 'tpmRequired' => 'TPM required', + 'secureBootEnabled' => 'Secure boot required', + 'codeIntegrityEnabled' => 'Code integrity required', + 'memoryIntegrityEnabled' => 'Memory integrity required', + 'kernelDmaProtectionEnabled' => 'Kernel DMA protection required', + 'firmwareProtectionEnabled' => 'Firmware protection required', + 'virtualizationBasedSecurityEnabled' => 'Virtualization-based security required', + 'earlyLaunchAntiMalwareDriverEnabled' => 'Early launch anti-malware required', + ], + ], + 'Operating System' => [ + 'keys' => [ + 'osMinimumVersion', + 'osMaximumVersion', + 'mobileOsMinimumVersion', + 'mobileOsMaximumVersion', + 'validOperatingSystemBuildRanges', + 'wslDistributions', + ], + 'labels' => [ + 'osMinimumVersion' => 'OS minimum version', + 'osMaximumVersion' => 'OS maximum version', + 'mobileOsMinimumVersion' => 'Mobile OS minimum version', + 'mobileOsMaximumVersion' => 'Mobile OS maximum version', + 'validOperatingSystemBuildRanges' => 'Valid OS build ranges', + 'wslDistributions' => 'Allowed WSL distributions', + ], + ], + 'Firewall' => [ + 'keys' => [ + 'activeFirewallRequired', + ], + 'labels' => [ + 'activeFirewallRequired' => 'Active firewall required', + ], + ], + 'Compliance Signals' => [ + 'keys' => [ + 'configurationManagerComplianceRequired', + 'deviceCompliancePolicyScript', + ], + 'labels' => [ + 'configurationManagerComplianceRequired' => 'ConfigMgr compliance required', + 'deviceCompliancePolicyScript' => 'Compliance policy script', + ], + ], + ]; + } + + /** + * @param array $labels + * @return array> + */ + private function buildRows(array $snapshot, array $keys, array $labels = []): array + { + $rows = []; + + foreach ($keys as $key) { + if (! array_key_exists($key, $snapshot)) { + continue; + } + + $rows[] = [ + 'label' => $labels[$key] ?? Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + + return $rows; + } + + /** + * @param array $usedKeys + * @return array> + */ + private function buildAdditionalRows(array $snapshot, array $usedKeys): array + { + $ignoredKeys = array_merge($this->ignoredKeys(), $usedKeys); + $rows = []; + + foreach ($snapshot as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (in_array($key, $ignoredKeys, true)) { + continue; + } + + $rows[] = [ + 'label' => Str::headline($key), + 'value' => $this->formatValue($value), + ]; + } + + return $rows; + } + + /** + * @return array + */ + private function ignoredKeys(): array + { + return [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + 'displayName', + 'description', + 'name', + 'platform', + 'platforms', + 'technologies', + 'settingCount', + 'settingsCount', + 'templateReference', + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php new file mode 100644 index 0000000..6cf7145 --- /dev/null +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -0,0 +1,918 @@ +>, settings_table?: array, warnings: array, context?: string, record_id?: string} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $resultWarnings = []; + $status = 'success'; + $settingsTable = null; + + $validation = $this->validator->validate($snapshot); + $resultWarnings = array_merge($resultWarnings, $validation['warnings']); + + $odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform); + + if ($odataWarning) { + $resultWarnings[] = $odataWarning; + } + + if ($snapshot === []) { + return [ + 'status' => 'warning', + 'settings' => [], + 'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))), + ]; + } + + $settings = []; + + if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) { + $settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']); + } + + if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { + if ($policyType === 'settingsCatalogPolicy') { + $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); + $settingsTable = $normalized['table']; + $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); + } else { + $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); + } + } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { + if ($policyType === 'settingsCatalogPolicy') { + $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); + $settingsTable = $normalized['table']; + $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); + } else { + $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); + } + } elseif ($policyType === 'settingsCatalogPolicy') { + $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; + } + + $settings[] = $this->normalizeStandard($snapshot); + + if (! empty($resultWarnings)) { + $status = 'warning'; + } + + $result = [ + 'status' => $status, + 'settings' => array_values(array_filter($settings)), + 'warnings' => array_values(array_unique($resultWarnings)), + ]; + + if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) { + $result['settings_table'] = $settingsTable; + } + + return $result; + } + + /** + * Flatten normalized settings into key/value pairs for diffing. + * + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + $map = []; + + if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { + foreach ($normalized['settings_table']['rows'] as $row) { + if (! is_array($row)) { + continue; + } + + $key = $row['path'] ?? $row['definition'] ?? 'entry'; + $map[$key] = $row['value'] ?? null; + } + } + + foreach ($normalized['settings'] as $block) { + if (($block['type'] ?? null) === 'table') { + foreach ($block['rows'] ?? [] as $row) { + $key = $row['path'] ?? $row['label'] ?? 'entry'; + $map[$key] = $row['value'] ?? null; + } + + continue; + } + + foreach ($block['entries'] ?? [] as $entry) { + $key = $entry['key'] ?? 'entry'; + $map[$key] = $entry['value'] ?? null; + } + } + + return $map; + } + + /** + * @param array> $omaSettings + */ + private function normalizeOmaSettings(array $omaSettings): array + { + $rows = []; + + foreach ($omaSettings as $setting) { + if (! is_array($setting)) { + continue; + } + + $rows[] = [ + 'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a', + 'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null, + 'label' => $setting['displayName'] ?? null, + 'description' => $setting['description'] ?? null, + ]; + } + + return [ + 'type' => 'table', + 'title' => 'OMA-URI settings', + 'rows' => $rows, + ]; + } + + /** + * @param array> $settings + */ + private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array + { + $entries = []; + + foreach ($settings as $setting) { + if (! is_array($setting)) { + continue; + } + + $key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting'; + $value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null; + + if ($value === null && isset($setting['value']['value'])) { + $value = $setting['value']['value']; + } + + if (is_array($value)) { + $value = json_encode($value, JSON_PRETTY_PRINT); + } + + $entries[] = [ + 'key' => $key, + 'value' => $value, + ]; + } + + return [ + 'type' => 'keyValue', + 'title' => $title, + 'entries' => $entries, + ]; + } + + /** + * @param array $settings + * @return array{table: array, warnings: array} + */ + private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array + { + $flattened = $this->flattenSettingsCatalogSettingInstances($settings); + + return [ + 'table' => [ + 'title' => $title, + 'rows' => $flattened['rows'], + ], + 'warnings' => $flattened['warnings'], + ]; + } + + /** + * @param array $settings + * @return array{rows: array>, warnings: array} + */ + private function flattenSettingsCatalogSettingInstances(array $settings): array + { + $rows = []; + $warnings = []; + $rowCount = 0; + $warnedDepthLimit = false; + $warnedRowLimit = false; + + // Extract all definition IDs first to resolve display names in batch + $definitionIds = $this->extractAllDefinitionIds($settings); + $definitions = $this->definitionResolver->resolve($definitionIds); + + // Extract all category IDs and resolve them + $categoryIds = array_filter(array_unique(array_map( + fn ($def) => $def['categoryId'] ?? null, + $definitions + ))); + $categories = $this->categoryResolver->resolve($categoryIds); + $categoryNames = []; + + foreach ($categoryIds as $categoryId) { + $categoryName = $categories[$categoryId]['displayName'] ?? null; + + if (is_string($categoryName) && $categoryName !== '') { + $categoryNames[] = $categoryName; + } + } + + $categoryNames = array_values(array_unique($categoryNames)); + $defaultCategoryName = count($categoryNames) === 1 ? $categoryNames[0] : null; + + $walk = function (array $nodes, array $pathParts, int $depth) use ( + &$walk, + &$rows, + &$warnings, + &$rowCount, + &$warnedDepthLimit, + &$warnedRowLimit, + $definitions, + $categories, + $defaultCategoryName + ): void { + if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { + if (! $warnedRowLimit) { + $warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS); + $warnedRowLimit = true; + } + + return; + } + + if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) { + if (! $warnedDepthLimit) { + $warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH); + $warnedDepthLimit = true; + } + + return; + } + + foreach ($nodes as $node) { + if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { + break; + } + + if (! is_array($node)) { + continue; + } + + $instance = $this->extractSettingsCatalogSettingInstance($node); + $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); + $instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null); + $rawInstanceType = is_string($instanceType) ? ltrim($instanceType, '#') : null; + + $currentPathParts = array_merge($pathParts, [$definitionId]); + $path = implode(' > ', $currentPathParts); + + $value = $this->extractSettingsCatalogValue($node, $instance); + + // Get metadata from resolved definitions + $definition = $definitions[$definitionId] ?? null; + $displayName = $definition['displayName'] ?? + $this->definitionResolver->prettifyDefinitionId($definitionId); + $categoryId = $definition['categoryId'] ?? null; + $categoryName = $categoryId ? ($categories[$categoryId]['displayName'] ?? '-') : '-'; + $description = $definition['description'] ?? null; + + if (! $categoryId && ! empty($pathParts)) { + foreach (array_reverse($pathParts) as $ancestorDefinitionId) { + if (! is_string($ancestorDefinitionId) || $ancestorDefinitionId === '') { + continue; + } + + $ancestorDefinition = $definitions[$ancestorDefinitionId] ?? null; + $ancestorCategoryId = $ancestorDefinition['categoryId'] ?? null; + + if ($ancestorCategoryId) { + $categoryId = $ancestorCategoryId; + $categoryName = $categories[$categoryId]['displayName'] ?? '-'; + break; + } + } + } + + if ( + ! $categoryId + && $defaultCategoryName + && (str_contains($definitionId, '{') || str_contains($definitionId, '}')) + ) { + $categoryName = $defaultCategoryName; + } + + // Convert technical type to user-friendly data type + $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); + + $rows[] = [ + 'definition' => $displayName, + 'definition_id' => $definitionId, + 'category' => $categoryName, + 'data_type' => $dataType, + 'value' => $this->stringifySettingsCatalogValue($value), + 'description' => $description ? Str::limit($description, 100) : '-', + 'path' => $path, + 'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node), + ]; + + $rowCount++; + + if (! is_array($instance)) { + continue; + } + + $nested = $this->extractSettingsCatalogChildren($instance); + + if (! empty($nested)) { + $walk($nested, $currentPathParts, $depth + 1); + } + + if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { + $collections = $instance['groupSettingCollectionValue'] ?? []; + + if (! is_array($collections)) { + continue; + } + + foreach (array_values($collections) as $index => $collection) { + if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { + break; + } + + if (! is_array($collection)) { + continue; + } + + $children = $collection['children'] ?? []; + + if (! is_array($children) || empty($children)) { + continue; + } + + $walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1); + } + } + } + }; + + $walk($settings, [], 1); + + return [ + 'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS), + 'warnings' => $warnings, + ]; + } + + private function extractSettingsCatalogSettingInstance(array $setting): ?array + { + $instance = $setting['settingInstance'] ?? null; + + if (is_array($instance)) { + return $instance; + } + + if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) { + return $setting; + } + + return null; + } + + private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string + { + $candidates = [ + $setting['definitionId'] ?? null, + $setting['settingDefinitionId'] ?? null, + $setting['name'] ?? null, + $setting['displayName'] ?? null, + $instance['settingDefinitionId'] ?? null, + $instance['definitionId'] ?? null, + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && $candidate !== '') { + return $candidate; + } + } + + return 'setting'; + } + + private function formatSettingsCatalogInstanceType(?string $type): ?string + { + if (! $type) { + return null; + } + + $type = Str::afterLast($type, '.'); + + foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) { + if (Str::startsWith($type, $prefix)) { + $type = substr($type, strlen($prefix)); + + break; + } + } + + return $type !== '' ? $type : null; + } + + private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool + { + $type = $instance['@odata.type'] ?? null; + + if (! is_string($type)) { + return false; + } + + return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true); + } + + /** + * @return array + */ + private function extractSettingsCatalogChildren(array $instance): array + { + foreach (['children', 'choiceSettingValue.children', 'groupSettingValue.children'] as $path) { + $children = Arr::get($instance, $path); + + if (is_array($children) && ! empty($children)) { + return $children; + } + } + + return []; + } + + private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed + { + if ($instance === null) { + return $setting['value'] ?? null; + } + + $type = $instance['@odata.type'] ?? null; + $type = is_string($type) ? $type : ''; + + if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { + $simple = $instance['simpleSettingValue'] ?? null; + + if (is_array($simple)) { + return $simple['value'] ?? $simple; + } + + return $simple; + } + + if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) { + $choice = $instance['choiceSettingValue'] ?? null; + + if (is_array($choice)) { + return $choice['value'] ?? $choice; + } + + return $choice; + } + + if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) { + return '(group)'; + } + + $fallback = $instance; + unset($fallback['children']); + + return $fallback; + } + + private function stringifySettingsCatalogValue(mixed $value): string + { + if ($value === null) { + return '-'; + } + + return $this->formatSettingsCatalogValue($value); + } + + private function pruneSettingsCatalogRaw(mixed $raw): mixed + { + if (! is_array($raw)) { + return $raw; + } + + $pruned = $raw; + unset($pruned['children'], $pruned['groupSettingCollectionValue']); + + return $pruned; + } + + private function normalizeStandard(array $snapshot): array + { + $metadataKeys = [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + ]; + + $filtered = Arr::except($snapshot, $metadataKeys); + $entries = []; + + foreach ($filtered as $key => $value) { + if (is_array($value)) { + $value = json_encode($value, JSON_PRETTY_PRINT); + } + + $entries[] = [ + 'key' => Str::headline((string) $key), + 'value' => $value, + ]; + } + + return [ + 'type' => 'keyValue', + 'title' => 'General', + 'entries' => $entries, + ]; + } + + /** + * Normalize Settings Catalog policy with grouped, readable settings (T011-T014). + * + * @param array $settings + * @return array{type: string, groups: array>} + */ + public function normalizeSettingsCatalogGrouped(array $settings): array + { + // Extract all definition IDs + $definitionIds = $this->extractAllDefinitionIds($settings); + + // Resolve definitions + $definitions = $this->definitionResolver->resolve($definitionIds); + + // Flatten settings + $flattened = $this->flattenSettingsCatalogForGrouping($settings); + + // Group by category + $groups = $this->groupSettingsByCategory($flattened, $definitions); + + return [ + 'type' => 'settings_catalog_grouped', + 'groups' => $groups, + ]; + } + + /** + * Extract all definition IDs from settings array recursively. + */ + private function extractAllDefinitionIds(array $settings): array + { + $ids = []; + + foreach ($settings as $setting) { + // Top-level settings have settingInstance wrapper + if (isset($setting['settingInstance']['settingDefinitionId'])) { + $ids[] = $setting['settingInstance']['settingDefinitionId']; + $instance = $setting['settingInstance']; + } + // Nested children have settingDefinitionId directly (they ARE the instance) + elseif (isset($setting['settingDefinitionId'])) { + $ids[] = $setting['settingDefinitionId']; + $instance = $setting; + } else { + continue; + } + + // Handle nested children using the comprehensive children extraction method + $children = $this->extractSettingsCatalogChildren($instance); + if (! empty($children)) { + $childIds = $this->extractAllDefinitionIds($children); + $ids = array_merge($ids, $childIds); + } + + // Also handle nested children in group collections (fallback for legacy code) + if (isset($instance['groupSettingCollectionValue'])) { + foreach ($instance['groupSettingCollectionValue'] as $group) { + if (isset($group['children']) && is_array($group['children'])) { + $childIds = $this->extractAllDefinitionIds($group['children']); + $ids = array_merge($ids, $childIds); + } + } + } + } + + return array_unique($ids); + } + + /** + * Flatten settings for grouping with value formatting. + */ + private function flattenSettingsCatalogForGrouping(array $settings): array + { + $rows = []; + + $walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void { + foreach ($nodes as $node) { + if (! is_array($node)) { + continue; + } + + $instance = $this->extractSettingsCatalogSettingInstance($node); + $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); + $value = $this->extractSettingsCatalogValue($node, $instance); + $isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance); + + // Only add to rows if NOT a group collection (those are containers) + if (! $isGroupCollection) { + $rows[] = [ + 'definition_id' => $definitionId, + 'value_raw' => $value, + 'value_display' => $this->formatSettingsCatalogValue($value), + 'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null, + ]; + } + + // Handle nested children + if (is_array($instance)) { + $nested = $this->extractSettingsCatalogChildren($instance); + if (! empty($nested)) { + $walk($nested, array_merge($pathParts, [$definitionId])); + } + + // Handle group collections + if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { + $collections = $instance['groupSettingCollectionValue'] ?? []; + if (is_array($collections)) { + foreach ($collections as $collection) { + if (isset($collection['children']) && is_array($collection['children'])) { + $walk($collection['children'], array_merge($pathParts, [$definitionId])); + } + } + } + } + } + } + }; + + $walk($settings, []); + + return $rows; + } + + /** + * Format setting value for display (T012). + */ + private function formatSettingsCatalogValue(mixed $value): string + { + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_int($value)) { + return number_format($value); + } + + if (is_string($value)) { + // Remove {tenantid} placeholder + $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); + $value = preg_replace('/_+/', '_', $value); + + // Extract choice label from choice values (last meaningful part) + // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" + if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { + $parts = explode('_', $value); + $lastPart = end($parts); + + // Check for boolean-like values + if (in_array(strtolower($lastPart), ['true', 'false'])) { + return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; + } + + // If last part is just a number, take second-to-last too + if (is_numeric($lastPart) && count($parts) > 1) { + $secondLast = $parts[count($parts) - 2]; + + // Map common values + $mapping = [ + 'lowercaseletters' => 'Lowercase Letters', + 'uppercaseletters' => 'Uppercase Letters', + 'specialcharacters' => 'Special Characters', + 'digits' => 'Digits', + ]; + + if (isset($mapping[strtolower($secondLast)])) { + return $mapping[strtolower($secondLast)].': '.$lastPart; + } + + if (in_array((string) $lastPart, ['0', '1'], true)) { + return (string) $lastPart === '1' ? 'Enabled' : 'Disabled'; + } + + return Str::title($secondLast).': '.$lastPart; + } + + return Str::title($lastPart); + } + + // Truncate long strings + return Str::limit($value, 100); + } + + if (is_array($value)) { + return json_encode($value); + } + + return (string) $value; + } + + /** + * Group settings by category (T013). + */ + private function groupSettingsByCategory(array $rows, array $definitions): array + { + $grouped = []; + + foreach ($rows as $row) { + $definitionId = $row['definition_id']; + $definition = $definitions[$definitionId] ?? null; + + // Determine category + $categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId); + $categoryTitle = $this->formatCategoryTitle($categoryId); + + if (! isset($grouped[$categoryId])) { + $grouped[$categoryId] = [ + 'title' => $categoryTitle, + 'description' => null, + 'settings' => [], + ]; + } + + $grouped[$categoryId]['settings'][] = [ + 'label' => $definition['displayName'] ?? $row['definition_id'], + 'value' => $row['value_display'], // Primary value for display + 'value_display' => $row['value_display'], + 'value_raw' => $row['value_raw'], + 'help_text' => $definition['helpText'] ?? $definition['description'] ?? null, + 'definition_id' => $definitionId, + 'instance_type' => $row['instance_type'], + 'is_fallback' => $definition['isFallback'] ?? false, + ]; + } + + // Sort groups by title + uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title'])); + + // Sort settings within each group by label for stable ordering + foreach ($grouped as $cid => $g) { + if (isset($g['settings']) && is_array($g['settings'])) { + usort($g['settings'], function ($a, $b) { + return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? '')); + }); + + $grouped[$cid]['settings'] = $g['settings']; + } + } + + return array_values($grouped); + } + + /** + * Extract category from definition ID (fallback grouping). + */ + private function extractCategoryFromDefinitionId(string $definitionId): string + { + $parts = explode('_', $definitionId); + + // Use first 2-3 segments as category + return implode('_', array_slice($parts, 0, min(3, count($parts)))); + } + + /** + * Format category ID into readable title. + */ + private function formatCategoryTitle(string $categoryId): string + { + // Try to prettify known patterns + if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) { + // It's a UUID - likely a category ID from Graph + return 'Additional Settings'; + } + + // Clean up common prefixes + $title = str_replace('device_vendor_msft_', '', $categoryId); + $title = Str::title(str_replace('_', ' ', $title)); + + // Known mappings + $mappings = [ + 'Passportforwork' => 'Windows Hello for Business', + ]; + + foreach ($mappings as $search => $replace) { + $title = str_replace($search, $replace, $title); + } + + return $title; + } + + /** + * Convert technical instance type to user-friendly data type. + */ + private function getUserFriendlyDataType(?string $instanceType, mixed $value): string + { + if (! $instanceType) { + return $this->guessDataTypeFromValue($value); + } + + $type = strtolower($instanceType); + + if (str_contains($type, 'choice')) { + return 'Choice'; + } + + if (str_contains($type, 'simplesetting')) { + return $this->guessDataTypeFromValue($value); + } + + if (str_contains($type, 'groupsetting')) { + return 'Group'; + } + + return 'Text'; + } + + /** + * Guess data type from value. + */ + private function guessDataTypeFromValue(mixed $value): string + { + if (is_bool($value)) { + return 'Boolean'; + } + + if (is_int($value)) { + return 'Number'; + } + + if (is_string($value)) { + // Check if it's a boolean-like string + if (in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'])) { + return 'Boolean'; + } + + // Check if numeric string + if (is_numeric($value)) { + return 'Number'; + } + + return 'Text'; + } + + if (is_array($value)) { + return 'List'; + } + + return 'Text'; + } +} diff --git a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php new file mode 100644 index 0000000..5c88051 --- /dev/null +++ b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php @@ -0,0 +1,126 @@ +>, 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; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $configurationBlock = $this->buildConfigurationBlock($snapshot); + + if ($configurationBlock !== null) { + $normalized['settings'][] = $configurationBlock; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } + + /** + * @return array{type: string, title: string, entries: array>}|null + */ + private function buildConfigurationBlock(array $snapshot): ?array + { + $entries = []; + $ignoredKeys = $this->ignoredKeys(); + + foreach ($snapshot as $key => $value) { + if (! is_string($key)) { + continue; + } + + if (in_array($key, $ignoredKeys, true)) { + continue; + } + + $entries[] = [ + 'key' => Str::headline($key), + 'value' => $this->formatValue($value), + ]; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Configuration', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + private function ignoredKeys(): array + { + return [ + '@odata.context', + '@odata.type', + 'id', + 'version', + 'createdDateTime', + 'lastModifiedDateTime', + 'supportsScopeTags', + 'roleScopeTagIds', + 'assignments', + 'createdBy', + 'lastModifiedBy', + 'omaSettings', + 'settings', + 'settingsDelta', + 'displayName', + 'description', + 'name', + 'platform', + 'platforms', + 'technologies', + 'settingCount', + 'settingsCount', + 'templateReference', + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index 0ac4573..b2f0971 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -58,7 +58,7 @@ public function capture( // 2. Fetch assignments if requested if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -242,7 +242,7 @@ public function ensureVersionHasAssignments( if ($includeAssignments && $version->assignments === null) { try { - $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $resolvedGroups = []; diff --git a/app/Services/Intune/PolicyNormalizer.php b/app/Services/Intune/PolicyNormalizer.php index 316b47d..e46544e 100644 --- a/app/Services/Intune/PolicyNormalizer.php +++ b/app/Services/Intune/PolicyNormalizer.php @@ -2,912 +2,65 @@ namespace App\Services\Intune; -use App\Models\Policy; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; +use Illuminate\Container\Attributes\Tag; class PolicyNormalizer { - private const SETTINGS_CATALOG_MAX_ROWS = 1000; - - private const SETTINGS_CATALOG_MAX_DEPTH = 8; - /** - * Normalize raw Intune snapshots into display-friendly blocks and warnings. + * @var array */ + private array $typeNormalizers; + public function __construct( - private readonly SnapshotValidator $validator, - private readonly SettingsCatalogDefinitionResolver $definitionResolver, - private readonly SettingsCatalogCategoryResolver $categoryResolver, - ) {} + private readonly DefaultPolicyNormalizer $defaultNormalizer, + #[Tag('policy-type-normalizers')] + iterable $typeNormalizers = [], + ) { + $normalizers = is_array($typeNormalizers) + ? $typeNormalizers + : iterator_to_array($typeNormalizers); - /** - * @return array{status: string, settings: array>, settings_table?: array, warnings: array, context?: string, record_id?: string} - */ - public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array - { - $snapshot = $snapshot ?? []; - $resultWarnings = []; - $status = 'success'; - $settingsTable = null; - - $validation = $this->validator->validate($snapshot); - $resultWarnings = array_merge($resultWarnings, $validation['warnings']); - - $odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform); - - if ($odataWarning) { - $resultWarnings[] = $odataWarning; - } - - if ($snapshot === []) { - return [ - 'status' => 'warning', - 'settings' => [], - 'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))), - ]; - } - - $settings = []; - - if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) { - $settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']); - } - - if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); - $settingsTable = $normalized['table']; - $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); - } else { - $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); - } - } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); - $settingsTable = $normalized['table']; - $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); - } else { - $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); - } - } elseif ($policyType === 'settingsCatalogPolicy') { - $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; - } - - $settings[] = $this->normalizeStandard($snapshot); - - if (! empty($resultWarnings)) { - $status = 'warning'; - } - - $result = [ - 'status' => $status, - 'settings' => array_values(array_filter($settings)), - 'warnings' => array_values(array_unique($resultWarnings)), - ]; - - if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) { - $result['settings_table'] = $settingsTable; - } - - return $result; + $this->typeNormalizers = array_values(array_filter( + $normalizers, + fn (mixed $normalizer) => $normalizer instanceof PolicyTypeNormalizer + )); + } + + /** + * @return array{status: string, settings: array>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->resolveNormalizer($policyType) + ->normalize($snapshot, $policyType, $platform); } /** - * Flatten normalized settings into key/value pairs for diffing. - * * @return array */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); - $map = []; - - if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { - foreach ($normalized['settings_table']['rows'] as $row) { - if (! is_array($row)) { - continue; - } - - $key = $row['path'] ?? $row['definition'] ?? 'entry'; - $map[$key] = $row['value'] ?? null; - } - } - - foreach ($normalized['settings'] as $block) { - if (($block['type'] ?? null) === 'table') { - foreach ($block['rows'] ?? [] as $row) { - $key = $row['path'] ?? $row['label'] ?? 'entry'; - $map[$key] = $row['value'] ?? null; - } - - continue; - } - - foreach ($block['entries'] ?? [] as $entry) { - $key = $entry['key'] ?? 'entry'; - $map[$key] = $entry['value'] ?? null; - } - } - - return $map; + return $this->resolveNormalizer($policyType) + ->flattenForDiff($snapshot, $policyType, $platform); } /** - * @param array> $omaSettings - */ - private function normalizeOmaSettings(array $omaSettings): array - { - $rows = []; - - foreach ($omaSettings as $setting) { - if (! is_array($setting)) { - continue; - } - - $rows[] = [ - 'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a', - 'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null, - 'label' => $setting['displayName'] ?? null, - 'description' => $setting['description'] ?? null, - ]; - } - - return [ - 'type' => 'table', - 'title' => 'OMA-URI settings', - 'rows' => $rows, - ]; - } - - /** - * @param array> $settings - */ - private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array - { - $entries = []; - - foreach ($settings as $setting) { - if (! is_array($setting)) { - continue; - } - - $key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting'; - $value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null; - - if ($value === null && isset($setting['value']['value'])) { - $value = $setting['value']['value']; - } - - if (is_array($value)) { - $value = json_encode($value, JSON_PRETTY_PRINT); - } - - $entries[] = [ - 'key' => $key, - 'value' => $value, - ]; - } - - return [ - 'type' => 'keyValue', - 'title' => $title, - 'entries' => $entries, - ]; - } - - /** - * @param array $settings - * @return array{table: array, warnings: array} - */ - private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array - { - $flattened = $this->flattenSettingsCatalogSettingInstances($settings); - - return [ - 'table' => [ - 'title' => $title, - 'rows' => $flattened['rows'], - ], - 'warnings' => $flattened['warnings'], - ]; - } - - /** - * @param array $settings - * @return array{rows: array>, warnings: array} - */ - private function flattenSettingsCatalogSettingInstances(array $settings): array - { - $rows = []; - $warnings = []; - $rowCount = 0; - $warnedDepthLimit = false; - $warnedRowLimit = false; - - // Extract all definition IDs first to resolve display names in batch - $definitionIds = $this->extractAllDefinitionIds($settings); - $definitions = $this->definitionResolver->resolve($definitionIds); - - // Extract all category IDs and resolve them - $categoryIds = array_filter(array_unique(array_map( - fn ($def) => $def['categoryId'] ?? null, - $definitions - ))); - $categories = $this->categoryResolver->resolve($categoryIds); - $categoryNames = []; - - foreach ($categoryIds as $categoryId) { - $categoryName = $categories[$categoryId]['displayName'] ?? null; - - if (is_string($categoryName) && $categoryName !== '') { - $categoryNames[] = $categoryName; - } - } - - $categoryNames = array_values(array_unique($categoryNames)); - $defaultCategoryName = count($categoryNames) === 1 ? $categoryNames[0] : null; - - $walk = function (array $nodes, array $pathParts, int $depth) use ( - &$walk, - &$rows, - &$warnings, - &$rowCount, - &$warnedDepthLimit, - &$warnedRowLimit, - $definitions, - $categories, - $defaultCategoryName - ): void { - if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { - if (! $warnedRowLimit) { - $warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS); - $warnedRowLimit = true; - } - - return; - } - - if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) { - if (! $warnedDepthLimit) { - $warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH); - $warnedDepthLimit = true; - } - - return; - } - - foreach ($nodes as $node) { - if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { - break; - } - - if (! is_array($node)) { - continue; - } - - $instance = $this->extractSettingsCatalogSettingInstance($node); - $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); - $instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null); - $rawInstanceType = is_string($instanceType) ? ltrim($instanceType, '#') : null; - - $currentPathParts = array_merge($pathParts, [$definitionId]); - $path = implode(' > ', $currentPathParts); - - $value = $this->extractSettingsCatalogValue($node, $instance); - - // Get metadata from resolved definitions - $definition = $definitions[$definitionId] ?? null; - $displayName = $definition['displayName'] ?? - $this->definitionResolver->prettifyDefinitionId($definitionId); - $categoryId = $definition['categoryId'] ?? null; - $categoryName = $categoryId ? ($categories[$categoryId]['displayName'] ?? '-') : '-'; - $description = $definition['description'] ?? null; - - if (! $categoryId && ! empty($pathParts)) { - foreach (array_reverse($pathParts) as $ancestorDefinitionId) { - if (! is_string($ancestorDefinitionId) || $ancestorDefinitionId === '') { - continue; - } - - $ancestorDefinition = $definitions[$ancestorDefinitionId] ?? null; - $ancestorCategoryId = $ancestorDefinition['categoryId'] ?? null; - - if ($ancestorCategoryId) { - $categoryId = $ancestorCategoryId; - $categoryName = $categories[$categoryId]['displayName'] ?? '-'; - break; - } - } - } - - if ( - ! $categoryId - && $defaultCategoryName - && (str_contains($definitionId, '{') || str_contains($definitionId, '}')) - ) { - $categoryName = $defaultCategoryName; - } - - // Convert technical type to user-friendly data type - $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); - - $rows[] = [ - 'definition' => $displayName, - 'definition_id' => $definitionId, - 'category' => $categoryName, - 'data_type' => $dataType, - 'value' => $this->stringifySettingsCatalogValue($value), - 'description' => $description ? Str::limit($description, 100) : '-', - 'path' => $path, - 'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node), - ]; - - $rowCount++; - - if (! is_array($instance)) { - continue; - } - - $nested = $this->extractSettingsCatalogChildren($instance); - - if (! empty($nested)) { - $walk($nested, $currentPathParts, $depth + 1); - } - - if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { - $collections = $instance['groupSettingCollectionValue'] ?? []; - - if (! is_array($collections)) { - continue; - } - - foreach (array_values($collections) as $index => $collection) { - if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { - break; - } - - if (! is_array($collection)) { - continue; - } - - $children = $collection['children'] ?? []; - - if (! is_array($children) || empty($children)) { - continue; - } - - $walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1); - } - } - } - }; - - $walk($settings, [], 1); - - return [ - 'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS), - 'warnings' => $warnings, - ]; - } - - private function extractSettingsCatalogSettingInstance(array $setting): ?array - { - $instance = $setting['settingInstance'] ?? null; - - if (is_array($instance)) { - return $instance; - } - - if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) { - return $setting; - } - - return null; - } - - private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string - { - $candidates = [ - $setting['definitionId'] ?? null, - $setting['settingDefinitionId'] ?? null, - $setting['name'] ?? null, - $setting['displayName'] ?? null, - $instance['settingDefinitionId'] ?? null, - $instance['definitionId'] ?? null, - ]; - - foreach ($candidates as $candidate) { - if (is_string($candidate) && $candidate !== '') { - return $candidate; - } - } - - return 'setting'; - } - - private function formatSettingsCatalogInstanceType(?string $type): ?string - { - if (! $type) { - return null; - } - - $type = Str::afterLast($type, '.'); - - foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) { - if (Str::startsWith($type, $prefix)) { - $type = substr($type, strlen($prefix)); - - break; - } - } - - return $type !== '' ? $type : null; - } - - private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool - { - $type = $instance['@odata.type'] ?? null; - - if (! is_string($type)) { - return false; - } - - return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true); - } - - /** - * @return array - */ - private function extractSettingsCatalogChildren(array $instance): array - { - foreach (['children', 'choiceSettingValue.children', 'groupSettingValue.children'] as $path) { - $children = Arr::get($instance, $path); - - if (is_array($children) && ! empty($children)) { - return $children; - } - } - - return []; - } - - private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed - { - if ($instance === null) { - return $setting['value'] ?? null; - } - - $type = $instance['@odata.type'] ?? null; - $type = is_string($type) ? $type : ''; - - if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { - $simple = $instance['simpleSettingValue'] ?? null; - - if (is_array($simple)) { - return $simple['value'] ?? $simple; - } - - return $simple; - } - - if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) { - $choice = $instance['choiceSettingValue'] ?? null; - - if (is_array($choice)) { - return $choice['value'] ?? $choice; - } - - return $choice; - } - - if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) { - return '(group)'; - } - - $fallback = $instance; - unset($fallback['children']); - - return $fallback; - } - - private function stringifySettingsCatalogValue(mixed $value): string - { - if ($value === null) { - return '-'; - } - - return $this->formatSettingsCatalogValue($value); - } - - private function pruneSettingsCatalogRaw(mixed $raw): mixed - { - if (! is_array($raw)) { - return $raw; - } - - $pruned = $raw; - unset($pruned['children'], $pruned['groupSettingCollectionValue']); - - return $pruned; - } - - private function normalizeStandard(array $snapshot): array - { - $metadataKeys = [ - '@odata.context', - '@odata.type', - 'id', - 'version', - 'createdDateTime', - 'lastModifiedDateTime', - 'supportsScopeTags', - 'roleScopeTagIds', - 'assignments', - 'createdBy', - 'lastModifiedBy', - 'omaSettings', - 'settings', - 'settingsDelta', - ]; - - $filtered = Arr::except($snapshot, $metadataKeys); - $entries = []; - - foreach ($filtered as $key => $value) { - if (is_array($value)) { - $value = json_encode($value, JSON_PRETTY_PRINT); - } - - $entries[] = [ - 'key' => Str::headline((string) $key), - 'value' => $value, - ]; - } - - return [ - 'type' => 'keyValue', - 'title' => 'General', - 'entries' => $entries, - ]; - } - - /** - * Normalize Settings Catalog policy with grouped, readable settings (T011-T014). - * * @param array $settings * @return array{type: string, groups: array>} */ public function normalizeSettingsCatalogGrouped(array $settings): array { - // Extract all definition IDs - $definitionIds = $this->extractAllDefinitionIds($settings); - - // Resolve definitions - $definitions = $this->definitionResolver->resolve($definitionIds); - - // Flatten settings - $flattened = $this->flattenSettingsCatalogForGrouping($settings); - - // Group by category - $groups = $this->groupSettingsByCategory($flattened, $definitions); - - return [ - 'type' => 'settings_catalog_grouped', - 'groups' => $groups, - ]; + return $this->defaultNormalizer->normalizeSettingsCatalogGrouped($settings); } - /** - * Extract all definition IDs from settings array recursively. - */ - private function extractAllDefinitionIds(array $settings): array + private function resolveNormalizer(string $policyType): PolicyTypeNormalizer { - $ids = []; - - foreach ($settings as $setting) { - // Top-level settings have settingInstance wrapper - if (isset($setting['settingInstance']['settingDefinitionId'])) { - $ids[] = $setting['settingInstance']['settingDefinitionId']; - $instance = $setting['settingInstance']; - } - // Nested children have settingDefinitionId directly (they ARE the instance) - elseif (isset($setting['settingDefinitionId'])) { - $ids[] = $setting['settingDefinitionId']; - $instance = $setting; - } else { - continue; - } - - // Handle nested children using the comprehensive children extraction method - $children = $this->extractSettingsCatalogChildren($instance); - if (! empty($children)) { - $childIds = $this->extractAllDefinitionIds($children); - $ids = array_merge($ids, $childIds); - } - - // Also handle nested children in group collections (fallback for legacy code) - if (isset($instance['groupSettingCollectionValue'])) { - foreach ($instance['groupSettingCollectionValue'] as $group) { - if (isset($group['children']) && is_array($group['children'])) { - $childIds = $this->extractAllDefinitionIds($group['children']); - $ids = array_merge($ids, $childIds); - } - } + foreach ($this->typeNormalizers as $normalizer) { + if ($normalizer->supports($policyType)) { + return $normalizer; } } - return array_unique($ids); - } - - /** - * Flatten settings for grouping with value formatting. - */ - private function flattenSettingsCatalogForGrouping(array $settings): array - { - $rows = []; - - $walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void { - foreach ($nodes as $node) { - if (! is_array($node)) { - continue; - } - - $instance = $this->extractSettingsCatalogSettingInstance($node); - $definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance); - $value = $this->extractSettingsCatalogValue($node, $instance); - $isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance); - - // Only add to rows if NOT a group collection (those are containers) - if (! $isGroupCollection) { - $rows[] = [ - 'definition_id' => $definitionId, - 'value_raw' => $value, - 'value_display' => $this->formatSettingsCatalogValue($value), - 'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null, - ]; - } - - // Handle nested children - if (is_array($instance)) { - $nested = $this->extractSettingsCatalogChildren($instance); - if (! empty($nested)) { - $walk($nested, array_merge($pathParts, [$definitionId])); - } - - // Handle group collections - if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) { - $collections = $instance['groupSettingCollectionValue'] ?? []; - if (is_array($collections)) { - foreach ($collections as $collection) { - if (isset($collection['children']) && is_array($collection['children'])) { - $walk($collection['children'], array_merge($pathParts, [$definitionId])); - } - } - } - } - } - } - }; - - $walk($settings, []); - - return $rows; - } - - /** - * Format setting value for display (T012). - */ - private function formatSettingsCatalogValue(mixed $value): string - { - if (is_bool($value)) { - return $value ? 'Enabled' : 'Disabled'; - } - - if (is_int($value)) { - return number_format($value); - } - - if (is_string($value)) { - // Remove {tenantid} placeholder - $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); - $value = preg_replace('/_+/', '_', $value); - - // Extract choice label from choice values (last meaningful part) - // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" - if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { - $parts = explode('_', $value); - $lastPart = end($parts); - - // Check for boolean-like values - if (in_array(strtolower($lastPart), ['true', 'false'])) { - return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; - } - - // If last part is just a number, take second-to-last too - if (is_numeric($lastPart) && count($parts) > 1) { - $secondLast = $parts[count($parts) - 2]; - - // Map common values - $mapping = [ - 'lowercaseletters' => 'Lowercase Letters', - 'uppercaseletters' => 'Uppercase Letters', - 'specialcharacters' => 'Special Characters', - 'digits' => 'Digits', - ]; - - if (isset($mapping[strtolower($secondLast)])) { - return $mapping[strtolower($secondLast)].': '.$lastPart; - } - - if (in_array((string) $lastPart, ['0', '1'], true)) { - return (string) $lastPart === '1' ? 'Enabled' : 'Disabled'; - } - - return Str::title($secondLast).': '.$lastPart; - } - - return Str::title($lastPart); - } - - // Truncate long strings - return Str::limit($value, 100); - } - - if (is_array($value)) { - return json_encode($value); - } - - return (string) $value; - } - - /** - * Group settings by category (T013). - */ - private function groupSettingsByCategory(array $rows, array $definitions): array - { - $grouped = []; - - foreach ($rows as $row) { - $definitionId = $row['definition_id']; - $definition = $definitions[$definitionId] ?? null; - - // Determine category - $categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId); - $categoryTitle = $this->formatCategoryTitle($categoryId); - - if (! isset($grouped[$categoryId])) { - $grouped[$categoryId] = [ - 'title' => $categoryTitle, - 'description' => null, - 'settings' => [], - ]; - } - - $grouped[$categoryId]['settings'][] = [ - 'label' => $definition['displayName'] ?? $row['definition_id'], - 'value' => $row['value_display'], // Primary value for display - 'value_display' => $row['value_display'], - 'value_raw' => $row['value_raw'], - 'help_text' => $definition['helpText'] ?? $definition['description'] ?? null, - 'definition_id' => $definitionId, - 'instance_type' => $row['instance_type'], - 'is_fallback' => $definition['isFallback'] ?? false, - ]; - } - - // Sort groups by title - uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title'])); - - // Sort settings within each group by label for stable ordering - foreach ($grouped as $cid => $g) { - if (isset($g['settings']) && is_array($g['settings'])) { - usort($g['settings'], function ($a, $b) { - return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? '')); - }); - - $grouped[$cid]['settings'] = $g['settings']; - } - } - - return array_values($grouped); - } - - /** - * Extract category from definition ID (fallback grouping). - */ - private function extractCategoryFromDefinitionId(string $definitionId): string - { - $parts = explode('_', $definitionId); - - // Use first 2-3 segments as category - return implode('_', array_slice($parts, 0, min(3, count($parts)))); - } - - /** - * Format category ID into readable title. - */ - private function formatCategoryTitle(string $categoryId): string - { - // Try to prettify known patterns - if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) { - // It's a UUID - likely a category ID from Graph - return 'Additional Settings'; - } - - // Clean up common prefixes - $title = str_replace('device_vendor_msft_', '', $categoryId); - $title = Str::title(str_replace('_', ' ', $title)); - - // Known mappings - $mappings = [ - 'Passportforwork' => 'Windows Hello for Business', - ]; - - foreach ($mappings as $search => $replace) { - $title = str_replace($search, $replace, $title); - } - - return $title; - } - - /** - * Convert technical instance type to user-friendly data type. - */ - private function getUserFriendlyDataType(?string $instanceType, mixed $value): string - { - if (! $instanceType) { - return $this->guessDataTypeFromValue($value); - } - - $type = strtolower($instanceType); - - if (str_contains($type, 'choice')) { - return 'Choice'; - } - - if (str_contains($type, 'simplesetting')) { - return $this->guessDataTypeFromValue($value); - } - - if (str_contains($type, 'groupsetting')) { - return 'Group'; - } - - return 'Text'; - } - - /** - * Guess data type from value. - */ - private function guessDataTypeFromValue(mixed $value): string - { - if (is_bool($value)) { - return 'Boolean'; - } - - if (is_int($value)) { - return 'Number'; - } - - if (is_string($value)) { - // Check if it's a boolean-like string - if (in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'])) { - return 'Boolean'; - } - - // Check if numeric string - if (is_numeric($value)) { - return 'Number'; - } - - return 'Text'; - } - - if (is_array($value)) { - return 'List'; - } - - return 'Text'; + return $this->defaultNormalizer; } } diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 6ed859c..69cc514 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -36,11 +36,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr foreach ($types as $typeConfig) { $policyType = $typeConfig['type']; $platform = $typeConfig['platform'] ?? null; + $filter = $typeConfig['filter'] ?? null; $this->graphLogger->logRequest('list_policies', [ 'tenant' => $tenantIdentifier, 'policy_type' => $policyType, 'platform' => $platform, + 'filter' => $filter, ]); try { @@ -49,6 +51,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $platform, + 'filter' => $filter, ]); } catch (Throwable $throwable) { throw GraphErrorMapper::fromThrowable($throwable, [ diff --git a/app/Services/Intune/PolicyTypeNormalizer.php b/app/Services/Intune/PolicyTypeNormalizer.php new file mode 100644 index 0000000..c111dd4 --- /dev/null +++ b/app/Services/Intune/PolicyTypeNormalizer.php @@ -0,0 +1,18 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array; + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array; +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 06622fc..8fc3aab 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -43,9 +43,16 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt [$foundationItems, $policyItems] = $this->splitItems($items); + $notificationTemplateIds = $foundationItems + ->where('policy_type', 'notificationMessageTemplate') + ->pluck('policy_identifier') + ->filter() + ->values() + ->all(); + $foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? []; - $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) { + $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) { $existing = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) @@ -54,7 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt $restoreMode = $this->resolveRestoreMode($item->policy_type); - return [ + $preview = [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, 'policy_type' => $item->policy_type, @@ -68,6 +75,18 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt $item->platform ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null), ]; + + if ($item->policy_type === 'deviceCompliancePolicy') { + $preview = array_merge( + $preview, + $this->previewComplianceNotificationTemplates( + payload: is_array($item->payload) ? $item->payload : [], + availableTemplateIds: $notificationTemplateIds + ) + ); + } + + return $preview; })->all(); return array_merge($foundationPreview, $policyPreview); @@ -201,6 +220,16 @@ public function execute( try { $originalPayload = is_array($item->payload) ? $item->payload : []; $originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping); + $complianceActionSummary = null; + $complianceActionOutcomes = null; + + if ($item->policy_type === 'deviceCompliancePolicy') { + [$originalPayload, $complianceActionSummary, $complianceActionOutcomes] = $this->applyComplianceNotificationTemplateMapping( + payload: $originalPayload, + templateMapping: $foundationMappingByType['notificationMessageTemplate'] ?? [] + ); + } + $mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']); // sanitize high-level fields according to contract @@ -358,6 +387,25 @@ public function execute( $itemStatus = 'partial'; $resultReason = 'Assignments restored with failures'; } + + } + + if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') { + $itemStatus = 'partial'; + $resultReason = 'Compliance notification actions skipped'; + } + + if ($complianceActionSummary !== null) { + $this->auditComplianceActionMapping( + tenant: $tenant, + restoreRun: $restoreRun, + policyId: $item->policy_identifier, + policyType: $item->policy_type, + summary: $complianceActionSummary, + outcomes: $complianceActionOutcomes ?? [], + actorEmail: $actorEmail, + actorName: $actorName + ); } $result = $context + [ @@ -391,6 +439,14 @@ public function execute( $result['assignment_summary'] = $assignmentSummary; } + if ($complianceActionSummary !== null) { + $result['compliance_action_summary'] = $complianceActionSummary; + } + + if ($complianceActionOutcomes !== null) { + $result['compliance_action_outcomes'] = $complianceActionOutcomes; + } + $results[] = $result; $appliedPolicyId = $item->policy_identifier; @@ -599,6 +655,230 @@ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, return $payload; } + /** + * @param array $payload + * @param array $availableTemplateIds + * @return array + */ + private function previewComplianceNotificationTemplates(array $payload, array $availableTemplateIds): array + { + $templateIds = $this->collectComplianceNotificationTemplateIds($payload); + + if ($templateIds === []) { + return []; + } + + $available = array_values(array_unique($availableTemplateIds)); + $missing = array_values(array_diff($templateIds, $available)); + $summary = [ + 'total' => count($templateIds), + 'missing' => count($missing), + ]; + + $warning = null; + + if ($missing !== []) { + $warning = sprintf('Missing %d notification template(s); notification actions may be skipped.', count($missing)); + } + + return array_filter([ + 'compliance_action_summary' => $summary, + 'compliance_action_warning' => $warning, + 'compliance_action_missing_templates' => $missing !== [] ? $missing : null, + ], static fn ($value) => $value !== null); + } + + /** + * @param array $payload + * @param array $templateMapping + * @return array{0: array, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array>} + */ + private function applyComplianceNotificationTemplateMapping(array $payload, array $templateMapping): array + { + $scheduled = $payload['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return [$payload, null, null]; + } + + $rules = []; + $total = 0; + $mapped = 0; + $skipped = 0; + $outcomes = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + $rules[] = $rule; + + continue; + } + + $ruleName = $rule['ruleName'] ?? null; + $updatedConfigs = []; + + foreach ($configs as $config) { + if (! is_array($config)) { + $updatedConfigs[] = $config; + + continue; + } + + $actionType = $config['actionType'] ?? null; + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($actionType !== 'notification' || $templateKey === null) { + $updatedConfigs[] = $config; + + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + $updatedConfigs[] = $config; + + continue; + } + + $total++; + + if ($templateMapping === []) { + $outcomes[] = [ + 'status' => 'skipped', + 'template_id' => $templateId, + 'rule_name' => $ruleName, + 'reason' => 'Notification template mapping unavailable.', + ]; + $skipped++; + + continue; + } + + $mappedTemplateId = $templateMapping[$templateId] ?? null; + + if (! is_string($mappedTemplateId) || $mappedTemplateId === '') { + $outcomes[] = [ + 'status' => 'skipped', + 'template_id' => $templateId, + 'rule_name' => $ruleName, + 'reason' => 'Notification template mapping missing for template ID.', + ]; + $skipped++; + + continue; + } + + $config[$templateKey] = $mappedTemplateId; + $updatedConfigs[] = $config; + $mapped++; + + $outcomes[] = [ + 'status' => 'mapped', + 'template_id' => $templateId, + 'mapped_template_id' => $mappedTemplateId, + 'rule_name' => $ruleName, + ]; + } + + if ($updatedConfigs === []) { + continue; + } + + $rule['scheduledActionConfigurations'] = array_values($updatedConfigs); + $rules[] = $rule; + } + + if ($rules !== []) { + $payload['scheduledActionsForRule'] = array_values($rules); + } else { + unset($payload['scheduledActionsForRule']); + } + + if ($total === 0) { + return [$payload, null, null]; + } + + return [$payload, ['total' => $total, 'mapped' => $mapped, 'skipped' => $skipped], $outcomes]; + } + + /** + * @param array $payload + * @return array + */ + private function collectComplianceNotificationTemplateIds(array $payload): array + { + $scheduled = $payload['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return []; + } + + $ids = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + continue; + } + + foreach ($configs as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['actionType'] ?? null) !== 'notification') { + continue; + } + + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($templateKey === null) { + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + continue; + } + + $ids[] = $templateId; + } + } + + return array_values(array_unique($ids)); + } + + private function resolveNotificationTemplateKey(array $config): ?string + { + if (array_key_exists('notificationTemplateId', $config)) { + return 'notificationTemplateId'; + } + + if (array_key_exists('notificationMessageTemplateId', $config)) { + return 'notificationMessageTemplateId'; + } + + return null; + } + + private function isEmptyGuid(string $value): bool + { + return strtolower($value) === '00000000-0000-0000-0000-000000000000'; + } + /** * @param array> $entries */ @@ -653,6 +933,51 @@ private function auditFoundationMapping( } } + /** + * @param array{total:int,mapped:int,skipped:int} $summary + * @param array> $outcomes + */ + private function auditComplianceActionMapping( + Tenant $tenant, + RestoreRun $restoreRun, + string $policyId, + string $policyType, + array $summary, + array $outcomes, + ?string $actorEmail, + ?string $actorName + ): void { + $skipped = (int) ($summary['skipped'] ?? 0); + $status = $skipped > 0 ? 'warning' : 'success'; + $skippedTemplates = collect($outcomes) + ->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'skipped') + ->pluck('template_id') + ->filter() + ->values() + ->all(); + + $this->auditLogger->log( + tenant: $tenant, + action: 'restore.compliance.actions.mapped', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'total' => (int) ($summary['total'] ?? 0), + 'mapped' => (int) ($summary['mapped'] ?? 0), + 'skipped' => $skipped, + 'skipped_template_ids' => $skippedTemplates, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: $status + ); + } + /** * @param array|null $selectedItemIds */ diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php new file mode 100644 index 0000000..6fc64c1 --- /dev/null +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -0,0 +1,31 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + } +} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 3d4f4da..214fcb6 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -91,7 +91,7 @@ public function captureFromGraph( if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); if (! empty($rawAssignments)) { $resolvedGroups = []; diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 843ee0e..eb5274a 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -17,10 +17,18 @@ protected static function odataTypeMap(): array 'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration', 'all' => '#microsoft.graph.deviceConfiguration', ], + 'groupPolicyConfiguration' => [ + 'windows' => '#microsoft.graph.groupPolicyConfiguration', + 'all' => '#microsoft.graph.groupPolicyConfiguration', + ], 'settingsCatalogPolicy' => [ 'windows' => '#microsoft.graph.deviceManagementConfigurationPolicy', 'all' => '#microsoft.graph.deviceManagementConfigurationPolicy', ], + 'windowsUpdateRing' => [ + 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + ], 'deviceCompliancePolicy' => [ 'windows' => '#microsoft.graph.windows10CompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy', @@ -38,6 +46,14 @@ protected static function odataTypeMap(): array 'deviceManagementScript' => [ 'windows' => '#microsoft.graph.deviceManagementScript', ], + 'deviceShellScript' => [ + 'macOS' => '#microsoft.graph.deviceShellScript', + 'all' => '#microsoft.graph.deviceShellScript', + ], + 'deviceHealthScript' => [ + 'windows' => '#microsoft.graph.deviceHealthScript', + 'all' => '#microsoft.graph.deviceHealthScript', + ], 'enrollmentRestriction' => [ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration', ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index bdf58d4..e760db8 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -27,6 +27,34 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'groupPolicyConfiguration' => [ + 'resource' => 'deviceManagement/groupPolicyConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.groupPolicyConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'settingsCatalogPolicy' => [ 'resource' => 'deviceManagement/configurationPolicies', @@ -84,6 +112,27 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'windowsUpdateRing' => [ + 'resource' => 'deviceManagement/deviceConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsUpdateForBusinessConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{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'], @@ -99,6 +148,15 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', ], 'appProtectionPolicy' => [ 'resource' => 'deviceAppManagement/managedAppPolicies', @@ -135,6 +193,51 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'deviceShellScript' => [ + 'resource' => 'deviceManagement/deviceShellScripts', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceShellScript', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'deviceHealthScript' => [ + 'resource' => 'deviceManagement/deviceHealthScripts', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceHealthScript', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'enrollmentRestriction' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 87523f4..21684c1 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -77,8 +77,14 @@ [ 'key' => 'DeviceManagementScripts.ReadWrite.All', 'type' => 'application', - 'description' => 'Read directory data needed for tenant health checks.', - 'features' => ['script-management'], + 'description' => 'Manage Intune device management scripts and remediations.', + 'features' => ['policy-sync', 'backup', 'restore', 'scripts', 'remediations'], + ], + [ + 'key' => 'DeviceManagementScripts.Read.All', + 'type' => 'application', + 'description' => 'Read Intune device management scripts and remediations.', + 'features' => ['policy-sync', 'backup', 'scripts', 'remediations'], ], ], // Stub list of permissions already granted to the service principal (used for display in Tenant verification UI). diff --git a/config/tenantpilot.php b/config/tenantpilot.php index fe389b8..1e214df 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -8,6 +8,17 @@ 'category' => 'Configuration', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceConfigurations', + 'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'groupPolicyConfiguration', + 'label' => 'Administrative Templates', + 'category' => 'Configuration', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/groupPolicyConfigurations', 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -22,6 +33,17 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'windowsUpdateRing', + 'label' => 'Software Update Ring', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceConfigurations', + 'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', @@ -62,6 +84,26 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'deviceShellScript', + 'label' => 'macOS Shell Scripts', + 'category' => 'Scripts', + 'platform' => 'macOS', + 'endpoint' => 'deviceManagement/deviceShellScripts', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], + [ + 'type' => 'deviceHealthScript', + 'label' => 'Proactive Remediations', + 'category' => 'Scripts', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceHealthScripts', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium', + ], [ 'type' => 'enrollmentRestriction', 'label' => 'Enrollment Restrictions', @@ -88,7 +130,7 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", + 'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 00c852e..4fb8987 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -82,6 +82,12 @@ {{ $item['validation_warning'] }} @endif + + @if (! empty($item['compliance_action_warning'])) +
+ {{ $item['compliance_action_warning'] }} +
+ @endif @endforeach diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 7a41afb..7adccee 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -189,6 +189,51 @@ @endif @endif + @if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary'])) + @php + $summary = $item['compliance_action_summary']; + $complianceOutcomes = $item['compliance_action_outcomes'] ?? []; + $complianceIssues = collect($complianceOutcomes) + ->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped') + ->values(); + @endphp + +
+ Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped • + {{ (int) ($summary['skipped'] ?? 0) }} skipped +
+ + @if ($complianceIssues->isNotEmpty()) +
+ Compliance notification details +
+ @foreach ($complianceIssues as $outcome) +
+
+
+ Template {{ $outcome['template_id'] ?? 'unknown' }} +
+ + skipped + +
+ @if (! empty($outcome['rule_name'])) +
+ Rule: {{ $outcome['rule_name'] }} +
+ @endif + @if (! empty($outcome['reason'])) +
+ {{ $outcome['reason'] }} +
+ @endif +
+ @endforeach +
+
+ @endif + @endif + @if (! empty($item['created_policy_id'])) @php $createdMode = $item['created_policy_mode'] ?? null; diff --git a/specs/007-device-config-compliance/spec.md b/specs/007-device-config-compliance/spec.md index 59f7cb9..765f1c3 100644 --- a/specs/007-device-config-compliance/spec.md +++ b/specs/007-device-config-compliance/spec.md @@ -23,6 +23,7 @@ ### Full Scope (Phase 2+) - Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies. - Tenant administration: device cleanup rules; RBAC roles and role assignments. - Connectors and tokens (metadata-only): APNs; VPP tokens; managed Google Play; certificate connectors; remote help settings. +- Inventory / Properties catalog policies (deviceManagement/inventoryPolicies) deferred until required permissions are confirmed. ## Overview Expand backup and restore coverage for device configuration and compliance workloads, including scripts and remediations. This feature focuses on policy types that are already core to DR and rollback, and builds on existing foundations and assignment mapping capabilities. @@ -75,4 +76,3 @@ ## Success Criteria (mandatory) - **SC-007.1**: For a backup containing at least 10 mixed configuration/compliance items, restore completes with 100% of items in Applied, Partial, or Skipped with reason (no silent failures). - **SC-007.2**: At least 95% of assignments in a mixed restore are either applied successfully or explicitly skipped with a recorded reason. - **SC-007.3**: Restore preview for 100 selected items completes in under 2 minutes in a typical admin environment. - diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index e5c583a..1843d9c 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -72,3 +72,8 @@ ## Phase 5: Tests and Verification **Checkpoint**: Tests pass and formatting is clean. +--- + +## Deferred / Backlog + +- [ ] T019 [Deferred] Add inventory/properties catalog policies (`deviceManagement/inventoryPolicies`) once required permissions are confirmed; include contracts, sync, snapshot hydration via `/settings`, and normalized UI display. diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 587329c..45ec5a2 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -193,7 +193,7 @@ // "Device Vendor Msft Policy Config Uncached Test Setting" })->skip('Manual UI verification required'); -it('does not show Settings tab for non-Settings Catalog policies', function () { +it('shows tabbed layout for non-Settings Catalog policies', function () { $tenant = Tenant::create([ 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), 'name' => 'Test Tenant', @@ -234,7 +234,9 @@ ->get(PolicyResource::getUrl('view', ['record' => $policy])); $response->assertOk(); - // Verify page renders successfully for non-Settings Catalog policies + $response->assertSee('General'); + $response->assertSee('Settings'); + $response->assertSee('JSON'); }); // T034: Test display names shown (not definition IDs) diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 252aafe..e13c564 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -182,3 +182,103 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'resource_id' => (string) $run->id, ]); }); + +test('restore execution records compliance notification mapping outcomes', 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 request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-3', + 'name' => 'Tenant Three', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-3', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Policy', + 'platform' => 'windows', + ]); + + $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' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Password', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-1', + ], + ], + ], + ], + ], + ]); + + $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('partial'); + expect($run->results[0]['status'])->toBe('partial'); + expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.compliance.actions.mapped', + 'resource_id' => (string) $run->id, + ]); +}); diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index 75196aa..fc441dc 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -103,3 +103,89 @@ public function request(string $method, string $path, array $options = []): Grap $policyPreview = collect($preview)->first(fn (array $item) => isset($item['action'])); expect($policyPreview['action'])->toBe('update'); }); + +test('restore preview warns about missing compliance notification templates', 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-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Policy', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + 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' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Password', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-1', + ], + ], + ], + ], + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet); + + $policyPreview = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceCompliancePolicy'); + + expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull(); + expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index f685067..bab434a 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -1,6 +1,7 @@ graphClient = Mockery::mock(MicrosoftGraphClient::class); - $this->fetcher = new AssignmentFetcher($this->graphClient); + $this->fetcher = new AssignmentFetcher($this->graphClient, app(GraphContractRegistry::class)); }); test('primary endpoint success', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; $assignments = [ ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], ['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']], @@ -35,7 +37,7 @@ ]) ->andReturn($response); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe($assignments); }); @@ -43,6 +45,7 @@ test('fallback on empty response', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; $assignments = [ ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], ]; @@ -70,7 +73,7 @@ $this->graphClient ->shouldReceive('request') ->once() - ->with('GET', '/deviceManagement/configurationPolicies', [ + ->with('GET', 'deviceManagement/configurationPolicies', [ 'tenant' => $tenantId, 'query' => [ '$expand' => 'assignments', @@ -79,7 +82,7 @@ ]) ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe($assignments); }); @@ -87,13 +90,14 @@ test('fail soft on error', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; $this->graphClient ->shouldReceive('request') ->once() ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); @@ -101,6 +105,7 @@ test('returns empty array when both endpoints return empty', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; // Primary returns empty $primaryResponse = new GraphResponse( @@ -123,10 +128,10 @@ $this->graphClient ->shouldReceive('request') ->once() - ->with('GET', '/deviceManagement/configurationPolicies', Mockery::any()) + ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); @@ -134,6 +139,7 @@ test('fallback handles missing assignments key', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; // Primary returns empty $primaryResponse = new GraphResponse( @@ -157,7 +163,7 @@ ->once() ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($tenantId, $policyId); + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); }); diff --git a/tests/Unit/CompliancePolicyNormalizerTest.php b/tests/Unit/CompliancePolicyNormalizerTest.php new file mode 100644 index 0000000..a2c5bca --- /dev/null +++ b/tests/Unit/CompliancePolicyNormalizerTest.php @@ -0,0 +1,36 @@ + '#microsoft.graph.windows10CompliancePolicy', + 'passwordRequired' => true, + 'passwordMinimumLength' => 8, + 'defenderEnabled' => true, + 'bitLockerEnabled' => false, + 'osMinimumVersion' => '10.0.19045', + 'activeFirewallRequired' => true, + 'customSetting' => 'Custom value', + ]; + + $normalized = $normalizer->normalize($snapshot, 'deviceCompliancePolicy', 'windows'); + + $settings = collect($normalized['settings']); + + $passwordBlock = $settings->firstWhere('title', 'Password & Access'); + expect($passwordBlock)->not->toBeNull(); + expect(collect($passwordBlock['rows'])->pluck('label')->all()) + ->toContain('Password required', 'Password minimum length'); + + $additionalBlock = $settings->firstWhere('title', 'Additional Settings'); + expect($additionalBlock)->not->toBeNull(); + expect(collect($additionalBlock['rows'])->pluck('label')->all()) + ->toContain('Custom Setting'); + + expect($settings->pluck('title')->all())->not->toContain('General'); +}); diff --git a/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php b/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php new file mode 100644 index 0000000..913dd2d --- /dev/null +++ b/tests/Unit/DeviceConfigurationPolicyNormalizerTest.php @@ -0,0 +1,39 @@ + '#microsoft.graph.windows10CustomConfiguration', + 'displayName' => 'Device Config Policy', + 'description' => 'Test policy', + 'omaSettings' => [ + [ + 'displayName' => 'Setting A', + 'omaUri' => './Vendor/MSFT/SettingA', + 'value' => 'Enabled', + ], + ], + 'customSetting' => 'Custom value', + 'nestedSetting' => ['value' => 'Nested'], + ]; + + $normalized = $normalizer->normalize($snapshot, 'deviceConfiguration', 'windows'); + $settings = collect($normalized['settings']); + + $omaBlock = $settings->firstWhere('title', 'OMA-URI settings'); + expect($omaBlock)->not->toBeNull(); + + $configurationBlock = $settings->firstWhere('title', 'Configuration'); + expect($configurationBlock)->not->toBeNull(); + + $labels = collect($configurationBlock['entries'])->pluck('key')->all(); + expect($labels)->toContain('Custom Setting', 'Nested Setting'); + expect($labels)->not->toContain('Display Name'); + + expect($settings->pluck('title')->all())->not->toContain('General'); +}); diff --git a/tests/Unit/PolicyNormalizerRoutingTest.php b/tests/Unit/PolicyNormalizerRoutingTest.php new file mode 100644 index 0000000..50be439 --- /dev/null +++ b/tests/Unit/PolicyNormalizerRoutingTest.php @@ -0,0 +1,44 @@ + 'custom', + 'settings' => [], + 'warnings' => [], + ]; + } + + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + return ['custom' => true]; + } + }; + + $normalizer = new PolicyNormalizer($defaultNormalizer, [$customNormalizer]); + + $custom = $normalizer->normalize(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows'); + expect($custom['status'])->toBe('custom'); + + $customDiff = $normalizer->flattenForDiff(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows'); + expect($customDiff)->toBe(['custom' => true]); + + $fallback = $normalizer->normalize(['id' => 'policy-1'], 'unknownPolicy', 'windows'); + expect($fallback['status'])->not->toBe('custom'); +}); diff --git a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php new file mode 100644 index 0000000..fcc9a14 --- /dev/null +++ b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php @@ -0,0 +1,33 @@ + '#microsoft.graph.deviceManagementConfigurationPolicy', + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'settingsCatalogPolicy', 'windows'); + + $rows = $normalized['settings_table']['rows'] ?? []; + + expect($rows)->toHaveCount(1); + expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +}); -- 2.45.2 From ae16a394d8a3f518e531360d955ba07970b3d208 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 00:36:39 +0100 Subject: [PATCH 03/14] fix: create missing autopilot profile on restore --- app/Services/Graph/GraphContractRegistry.php | 3 +- app/Services/Intune/RestoreService.php | 105 +++++++++++++++++ config/graph_contracts.php | 15 +++ .../entries/restore-results.blade.php | 8 +- specs/007-device-config-compliance/tasks.md | 8 +- .../Feature/Filament/RestoreExecutionTest.php | 106 ++++++++++++++++++ .../GraphContractRegistryActualDataTest.php | 44 ++++++++ 7 files changed, 280 insertions(+), 9 deletions(-) diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 40b6a56..37afda0 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -69,8 +69,7 @@ public function sanitizeUpdatePayload(string $policyType, array $snapshot): arra $whitelist = $contract['update_whitelist'] ?? null; $stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []); $mapping = $contract['update_map'] ?? []; - - $stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']); + $stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys'])); $result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping); diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 8fc3aab..0cbb466 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -330,6 +330,26 @@ public function execute( $payload, $graphOptions ); + + if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { + $createOutcome = $this->createAutopilotDeploymentProfileIfMissing( + originalPayload: $originalPayload, + graphOptions: $graphOptions, + context: $context, + policyId: $item->policy_identifier, + ); + + if ($createOutcome['attempted']) { + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = 'created'; + $itemStatus = 'applied'; + $resultReason = 'Policy missing; created new Autopilot profile.'; + } + } + } } } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); @@ -1324,6 +1344,91 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} + */ + private function createAutopilotDeploymentProfileIfMissing( + array $originalPayload, + array $graphOptions, + array $context, + string $policyId, + ): array { + if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) { + return [ + 'attempted' => false, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile') + ?? 'deviceManagement/windowsAutopilotDeploymentProfiles'; + $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload); + + if ($payload === []) { + return [ + 'attempted' => true, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $this->graphLogger->logRequest('create_autopilot_profile', $context + [ + 'endpoint' => $resource, + 'method' => 'POST', + ]); + + $response = $this->graphClient->request( + 'POST', + $resource, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ); + + $this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [ + 'endpoint' => $resource, + 'method' => 'POST', + ]); + + $policyId = $this->extractCreatedPolicyId($response); + + return [ + 'attempted' => true, + 'success' => $response->successful(), + 'policy_id' => $policyId, + 'response' => $response, + ]; + } + + private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool + { + $response = $this->graphClient->getPolicy( + 'windowsAutopilotDeploymentProfile', + $policyId, + $graphOptions + ); + + if ($response->successful()) { + return false; + } + + if ($response->status === 404) { + return true; + } + + $code = strtolower((string) ($response->meta['error_code'] ?? '')); + $message = strtolower((string) ($response->meta['error_message'] ?? '')); + + if (str_contains($code, 'notfound') || str_contains($code, 'resource')) { + return true; + } + + return str_contains($message, 'not found') + || str_contains($message, 'resource not found') + || str_contains($message, 'does not exist'); + } + private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool { $code = strtolower((string) ($response->meta['error_code'] ?? '')); diff --git a/config/graph_contracts.php b/config/graph_contracts.php index e760db8..d637985 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -258,11 +258,26 @@ 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.windowsAutopilotDeploymentProfile', + '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile', ], 'create_method' => 'POST', 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'strip_odata' => false, + 'update_strip_keys' => [ + 'assignments', + 'managementServiceAppId', + 'outOfBoxExperienceSetting', + 'hardwareHashExtractionEnabled', + 'locale', + ], + 'assignments_list_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_delete_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', ], 'windowsEnrollmentStatusPage' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 7adccee..6d288b9 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -237,9 +237,11 @@ @if (! empty($item['created_policy_id'])) @php $createdMode = $item['created_policy_mode'] ?? null; - $createdMessage = $createdMode === 'metadata_only' - ? 'New policy created (metadata only). Apply settings manually.' - : 'New policy created (manual cleanup required).'; + $createdMessage = match ($createdMode) { + 'metadata_only' => 'New policy created (metadata only). Apply settings manually.', + 'created' => 'New policy created.', + default => 'New policy created (manual cleanup required).', + }; @endphp
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }} diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index 1843d9c..c84d79d 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -15,10 +15,10 @@ ## Phase 1: Policy Types, Contracts, Permissions **Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage. -- [ ] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk). -- [ ] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`. -- [ ] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`. -- [ ] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. +- [x] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk). +- [x] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`. +- [x] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`. +- [x] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. **Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry. diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index e13c564..b9bf0d0 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -282,3 +282,109 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon 'resource_id' => (string) $run->id, ]); }); + +test('restore execution creates an autopilot profile when missing', function () { + $graphClient = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + public int $getCalls = 0; + + public int $createCalls = 0; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->getCalls++; + + return new GraphResponse(false, [], 404, [], [], [ + 'error_code' => 'ResourceNotFound', + 'error_message' => 'Resource not found.', + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(false, [], 500, [], [], [ + 'error_code' => 'InternalServerError', + 'error_message' => 'An internal server error has occurred.', + ]); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) { + $this->createCalls++; + + return new GraphResponse(true, ['id' => 'autopilot-created']); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $graphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'autopilot-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile', + 'language' => 'en-US', + ], + ]) + ->create(); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($graphClient->applyCalls)->toBe(1); + expect($graphClient->getCalls)->toBe(1); + expect($graphClient->createCalls)->toBe(1); + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); +}); diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index 0bb4065..813aecf 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -49,3 +49,47 @@ // Null values should be preserved (Graph might need them) expect(array_key_exists('settingValueTemplateReference', $sanitized[0]['settingInstance']['choiceSettingValue']))->toBeTrue(); }); + +it('exposes autopilot assignments paths', function () { + $contract = $this->registry->get('windowsAutopilotDeploymentProfile'); + + expect($contract)->not->toBeEmpty(); + expect($contract['assignments_list_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments'); + expect($contract['assignments_create_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments'); + expect($contract['assignments_delete_path'] ?? null) + ->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}'); + expect($this->registry->matchesTypeFamily( + 'windowsAutopilotDeploymentProfile', + '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile' + ))->toBeTrue(); + expect($this->registry->matchesTypeFamily( + 'windowsAutopilotDeploymentProfile', + '#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile' + ))->toBeTrue(); +}); + +it('sanitizes autopilot update payload by stripping odata and assignments', function () { + $payload = [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'id' => 'profile-1', + 'displayName' => 'Autopilot Profile', + 'assignments' => [['id' => 'assignment-1']], + 'managementServiceAppId' => 'service-app', + 'outOfBoxExperienceSetting' => ['deviceUsageType' => 'shared'], + 'hardwareHashExtractionEnabled' => true, + 'locale' => 'de-DE', + ]; + + $sanitized = $this->registry->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $payload); + + expect($sanitized)->toHaveKey('displayName'); + expect($sanitized)->toHaveKey('@odata.type'); + expect($sanitized)->not->toHaveKey('id'); + expect($sanitized)->not->toHaveKey('assignments'); + expect($sanitized)->not->toHaveKey('managementServiceAppId'); + expect($sanitized)->not->toHaveKey('outOfBoxExperienceSetting'); + expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled'); + expect($sanitized)->not->toHaveKey('locale'); +}); -- 2.45.2 From d123a05a150cbd6f505d62316a9e235dc1c01d05 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 00:50:55 +0100 Subject: [PATCH 04/14] chore(spec): mark 007 phase 2 tasks complete --- specs/007-device-config-compliance/tasks.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index c84d79d..ab6fa32 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -28,10 +28,10 @@ ## Phase 2: Snapshot Capture and Metadata **Purpose**: Ensure snapshots, assignments, and scope tags are captured for the new workloads. -- [ ] T005 Update `app/Services/Intune/PolicySnapshotService.php` to fetch and hydrate the new policy types correctly (filters, select fields). -- [ ] T006 Extend `app/Services/Intune/PolicyCaptureOrchestrator.php` to capture assignments and scope tags for the new types with existing resolvers. -- [ ] T007 Update `app/Services/Intune/BackupService.php` to capture snapshots for the new types and propagate warnings. -- [ ] T008 Add or extend normalization support in `app/Services/Intune/PolicyNormalizer.php` for the new policy types. +- [x] T005 Update `app/Services/Intune/PolicySnapshotService.php` to fetch and hydrate the new policy types correctly (filters, select fields). +- [x] T006 Extend `app/Services/Intune/PolicyCaptureOrchestrator.php` to capture assignments and scope tags for the new types with existing resolvers. +- [x] T007 Update `app/Services/Intune/BackupService.php` to capture snapshots for the new types and propagate warnings. +- [x] T008 Add or extend normalization support in `app/Services/Intune/PolicyNormalizer.php` for the new policy types. **Checkpoint**: Backups include snapshots and metadata for configuration/compliance policies. -- 2.45.2 From 8aa9fd4d0f4950c929d410672becd6085c9e064e Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 14:51:18 +0100 Subject: [PATCH 05/14] fix: improve assignment capture and restore flows --- .../BackupItemsRelationManager.php | 19 +- app/Filament/Resources/RestoreRunResource.php | 5 + app/Services/AssignmentBackupService.php | 49 +++- app/Services/AssignmentRestoreService.php | 73 +++++- app/Services/Graph/AssignmentFetcher.php | 213 ++++++++++++++---- app/Services/Intune/BackupService.php | 8 + .../Intune/PolicyCaptureOrchestrator.php | 78 +++++-- app/Services/Intune/RestoreService.php | 44 +++- app/Services/Intune/VersionService.php | 60 ++++- config/graph_contracts.php | 11 + ...olicy-version-assignments-widget.blade.php | 26 ++- specs/007-device-config-compliance/tasks.md | 6 +- tests/Feature/Filament/BackupCreationTest.php | 67 +++++- .../Feature/Filament/RestoreExecutionTest.php | 4 + .../Filament/RestoreItemSelectionTest.php | 21 ++ .../Filament/SettingsCatalogRestoreTest.php | 2 + .../PolicyVersionViewAssignmentsTest.php | 20 ++ .../VersionCaptureWithAssignmentsTest.php | 68 +++++- tests/Unit/AssignmentBackupServiceTest.php | 93 ++++++++ tests/Unit/AssignmentFetcherTest.php | 29 ++- tests/Unit/AssignmentRestoreServiceTest.php | 202 +++++++++++++++++ 21 files changed, 987 insertions(+), 111 deletions(-) create mode 100644 tests/Unit/AssignmentBackupServiceTest.php create mode 100644 tests/Unit/AssignmentRestoreServiceTest.php diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 6c53439..dd20cf2 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -52,16 +52,26 @@ public function table(Table $table): Table ->label('Assignments') ->badge() ->color('info') - ->getStateUsing(function (BackupItem $record): int { - $assignments = $record->policyVersion?->assignments ?? $record->assignments ?? []; + ->getStateUsing(function (BackupItem $record): string { + $assignments = $record->policyVersion?->assignments ?? $record->assignments; - return is_array($assignments) ? count($assignments) : 0; + if (is_array($assignments)) { + return (string) count($assignments); + } + + $assignmentsFetched = $record->policyVersion?->metadata['assignments_fetched'] + ?? $record->metadata['assignments_fetched'] + ?? false; + + return $assignmentsFetched ? '0' : '—'; }), Tables\Columns\TextColumn::make('scope_tags') ->label('Scope Tags') ->default('—') ->getStateUsing(function (BackupItem $record): array { - $tags = $record->policyVersion?->scope_tags['names'] ?? []; + $tags = $record->policyVersion?->scope_tags['names'] + ?? $record->metadata['scope_tag_names'] + ?? []; return is_array($tags) ? $tags : []; }) @@ -100,6 +110,7 @@ public function table(Table $table): Table return Policy::query() ->where('tenant_id', $tenantId) + ->whereNull('ignored_at') ->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround) ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) ->orderBy('display_name') diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 50087c9..bbc4d25 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -543,6 +543,11 @@ private static function restoreItemOptionData(?int $backupSetId): array $items = BackupItem::query() ->where('backup_set_id', $backupSetId) ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) + ->where(function ($query) { + $query->whereNull('policy_id') + ->orWhereDoesntHave('policy') + ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); + }) ->with('policy:id,display_name') ->get() ->sortBy(function (BackupItem $item) { diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index 0c12fa3..9c3e941 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -93,12 +93,7 @@ public function enrichWithAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($assignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($assignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -183,9 +178,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -193,4 +200,28 @@ private function enrichAssignments(array $assignments, array $groups, array $fil return $assignment; }, $assignments); } + + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } } diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php index 6d32690..980749c 100644 --- a/app/Services/AssignmentRestoreService.php +++ b/app/Services/AssignmentRestoreService.php @@ -4,6 +4,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\Graph\AssignmentFilterResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphLogger; @@ -19,6 +20,7 @@ public function __construct( private readonly GraphContractRegistry $contracts, private readonly GraphLogger $graphLogger, private readonly AuditLogger $auditLogger, + private readonly AssignmentFilterResolver $assignmentFilterResolver, ) {} /** @@ -56,6 +58,11 @@ public function restore( $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); $usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign'); + $assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments'; + + if (! is_string($assignmentsPayloadKey) || $assignmentsPayloadKey === '') { + $assignmentsPayloadKey = 'assignments'; + } $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); $deletePathTemplate = $contract['assignments_delete_path'] ?? null; @@ -84,13 +91,39 @@ public function restore( $assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? []; + if ($assignmentFilterMapping === []) { + $filterIds = $this->extractAssignmentFilterIds($assignments); + + if ($filterIds !== []) { + $resolvedFilters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + + foreach ($resolvedFilters as $filter) { + $filterId = $filter['id'] ?? null; + + if (is_string($filterId) && $filterId !== '') { + $assignmentFilterMapping[$filterId] = $filterId; + } + } + } + } + foreach ($assignments as $assignment) { if (! is_array($assignment)) { continue; } $target = $assignment['target'] ?? []; - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterLocation = array_key_exists('deviceAndAppManagementAssignmentFilterId', $assignment) ? 'root' : 'target'; + + if (! is_string($filterId) && ! is_int($filterId)) { + $filterId = null; + } + + if (is_string($filterId) && $filterId === '') { + $filterId = null; + } if ($filterId !== null) { if ($assignmentFilterMapping === []) { @@ -142,8 +175,12 @@ public function restore( continue; } - $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; - $assignment['target'] = $target; + if ($filterLocation === 'root') { + $assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + } else { + $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + $assignment['target'] = $target; + } } $groupId = $assignment['target']['groupId'] ?? null; @@ -196,7 +233,7 @@ public function restore( ]); $assignResponse = $this->graphClient->request($createMethod, $createPath, [ - 'json' => ['assignments' => $preparedAssignments], + 'json' => [$assignmentsPayloadKey => $preparedAssignments], ] + $graphOptions); $this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [ @@ -413,6 +450,34 @@ private function resolvePath(?string $template, string $policyId, ?string $assig return $path; } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) || is_int($filterId)) { + $filterId = (string) $filterId; + + if ($filterId !== '') { + $filterIds[] = $filterId; + } + } + } + + return array_values(array_unique($filterIds)); + } + private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array { if (! $mappedGroupId) { diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 415881d..f7efa2d 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -19,84 +19,138 @@ public function __construct( * * @return array Returns assignment array or empty array on failure */ - public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array - { + public function fetch( + string $policyType, + string $tenantId, + string $policyId, + array $options = [], + bool $throwOnFailure = false + ): array { + $contract = $this->contracts->get($policyType); + $listPathTemplate = $contract['assignments_list_path'] ?? null; + $resource = $contract['resource'] ?? null; + $requestOptions = array_merge($options, ['tenant' => $tenantId]); + $context = [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]; + + $primaryException = null; + $assignments = []; + + // Try primary endpoint try { - $contract = $this->contracts->get($policyType); - $listPathTemplate = $contract['assignments_list_path'] ?? null; - $resource = $contract['resource'] ?? null; - $requestOptions = array_merge($options, ['tenant' => $tenantId]); + $assignments = $this->fetchPrimary( + $listPathTemplate, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); + } catch (GraphException $e) { + $primaryException = $e; + } - // Try primary endpoint - $assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions); + if (! empty($assignments)) { + Log::debug('Fetched assignments via primary endpoint', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); - if (! empty($assignments)) { - Log::debug('Fetched assignments via primary endpoint', [ - 'tenant_id' => $tenantId, - 'policy_type' => $policyType, - 'policy_id' => $policyId, - 'count' => count($assignments), - ]); + return $assignments; + } - return $assignments; - } + // Try fallback with $expand + Log::debug('Primary endpoint returned empty, trying fallback', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); - // Try fallback with $expand - Log::debug('Primary endpoint returned empty, trying fallback', [ + if (! is_string($resource) || $resource === '') { + Log::debug('Assignments resource not configured for policy type', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, ]); - if (! is_string($resource) || $resource === '') { - Log::debug('Assignments resource not configured for policy type', [ + if ($throwOnFailure && $primaryException) { + Log::warning('Failed to fetch assignments', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, + 'error' => $primaryException->getMessage(), + 'context' => $primaryException->context, ]); - return []; + throw $primaryException; } - $assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions); - - if (! empty($assignments)) { - Log::debug('Fetched assignments via fallback endpoint', [ - 'tenant_id' => $tenantId, - 'policy_type' => $policyType, - 'policy_id' => $policyId, - 'count' => count($assignments), - ]); - - return $assignments; - } - - // Both methods returned empty - Log::debug('No assignments found for policy', [ - 'tenant_id' => $tenantId, - 'policy_type' => $policyType, - 'policy_id' => $policyId, - ]); - return []; + } + + $fallbackException = null; + + try { + $assignments = $this->fetchWithExpand( + $resource, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); } catch (GraphException $e) { + $fallbackException = $e; + } + + if (! empty($assignments)) { + Log::debug('Fetched assignments via fallback endpoint', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); + + return $assignments; + } + + // Both methods returned empty + Log::debug('No assignments found for policy', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + if ($throwOnFailure && ($fallbackException || $primaryException)) { + $exception = $fallbackException ?? $primaryException; + Log::warning('Failed to fetch assignments', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, - 'error' => $e->getMessage(), - 'context' => $e->context, + 'error' => $exception->getMessage(), + 'context' => $exception->context, ]); - return []; + throw $exception; } + + return []; } /** * Fetch assignments using primary endpoint. */ - private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array - { + private function fetchPrimary( + ?string $listPathTemplate, + string $policyId, + array $options, + array $context, + bool $throwOnFailure + ): array { if (! is_string($listPathTemplate) || $listPathTemplate === '') { return []; } @@ -109,14 +163,33 @@ private function fetchPrimary(?string $listPathTemplate, string $policyId, array $response = $this->graphClient->request('GET', $path, $options); + if ($response->failed()) { + $this->logAssignmentFailure('primary', $response, $context + ['path' => $path]); + + if ($throwOnFailure) { + throw new GraphException( + $this->resolveErrorMessage($response), + $response->status, + $context + ['path' => $path] + ); + } + + return []; + } + return $response->data['value'] ?? []; } /** * Fetch assignments using $expand fallback. */ - private function fetchWithExpand(string $resource, string $policyId, array $options): array - { + private function fetchWithExpand( + string $resource, + string $policyId, + array $options, + array $context, + bool $throwOnFailure + ): array { $path = $resource; $params = [ '$expand' => 'assignments', @@ -127,6 +200,20 @@ private function fetchWithExpand(string $resource, string $policyId, array $opti 'query' => $params, ])); + if ($response->failed()) { + $this->logAssignmentFailure('fallback', $response, $context + ['path' => $path]); + + if ($throwOnFailure) { + throw new GraphException( + $this->resolveErrorMessage($response), + $response->status, + $context + ['path' => $path] + ); + } + + return []; + } + $policies = $response->data['value'] ?? []; if (empty($policies)) { @@ -144,4 +231,32 @@ private function resolvePath(string $template, string $policyId): ?string return str_replace('{id}', urlencode($policyId), $template); } + + private function resolveErrorMessage(GraphResponse $response): string + { + $error = $response->errors[0] ?? null; + + if (is_array($error)) { + if (isset($error['message']) && is_string($error['message'])) { + return $error['message']; + } + + return json_encode($error, JSON_UNESCAPED_SLASHES) ?: 'Graph request failed'; + } + + if (is_string($error) && $error !== '') { + return $error; + } + + return 'Graph request failed'; + } + + private function logAssignmentFailure(string $stage, GraphResponse $response, array $context): void + { + Log::warning('Assignment fetch failed', $context + [ + 'stage' => $stage, + 'status' => $response->status, + 'errors' => $response->errors, + ]); + } } diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index b41dd01..22abe4c 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -42,6 +42,7 @@ public function createBackupSet( $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) + ->whereNull('ignored_at') ->get(); $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) { @@ -182,6 +183,7 @@ public function addPoliciesToSet( $policies = Policy::query() ->where('tenant_id', $tenant->id) ->whereIn('id', $policyIds) + ->whereNull('ignored_at') ->get(); $metadata = $backupSet->metadata ?? []; @@ -303,6 +305,12 @@ private function snapshotPolicy( $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } + $capturedScopeTags = $captured['scope_tags'] ?? null; + if (is_array($capturedScopeTags)) { + $metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null; + $metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null; + } + // Create BackupItem as a copy/reference of the PolicyVersion $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index b2f0971..63e6f27 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -58,7 +58,15 @@ public function capture( // 2. Fetch assignments if requested if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch( + $policy->policy_type, + $tenantIdentifier, + $policy->external_id, + $graphOptions, + true + ); + $captureMetadata['assignments_fetched'] = true; + $captureMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -77,12 +85,7 @@ public function capture( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -90,7 +93,6 @@ public function capture( ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); - $captureMetadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $captureMetadata['assignments_fetch_failed'] = true; @@ -242,7 +244,15 @@ public function ensureVersionHasAssignments( if ($includeAssignments && $version->assignments === null) { try { - $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch( + $policy->policy_type, + $tenantIdentifier, + $policy->external_id, + $graphOptions, + true + ); + $metadata['assignments_fetched'] = true; + $metadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -261,12 +271,7 @@ public function ensureVersionHasAssignments( ->contains(fn (array $group) => $group['orphaned'] ?? false); } - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -274,7 +279,6 @@ public function ensureVersionHasAssignments( ->all(); $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); - $metadata['assignments_count'] = count($rawAssignments); } } catch (\Throwable $e) { $metadata['assignments_fetch_failed'] = true; @@ -336,9 +340,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -347,6 +363,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil }, $assignments); } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 0cbb466..c5a36a1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -276,7 +276,7 @@ public function execute( settings: $settings, graphOptions: $graphOptions, context: $context, - fallbackName: $item->policy_identifier, + fallbackName: $item->resolvedDisplayName(), ); if ($createOutcome['success']) { @@ -385,6 +385,7 @@ public function execute( $assignmentOutcomes = null; $assignmentSummary = null; + $restoredAssignments = null; if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; @@ -410,6 +411,19 @@ public function execute( } + if (is_array($assignmentOutcomes)) { + $restoredAssignments = collect($assignmentOutcomes['outcomes'] ?? []) + ->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'success') + ->pluck('assignment') + ->filter() + ->values() + ->all(); + + if ($restoredAssignments === []) { + $restoredAssignments = null; + } + } + if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') { $itemStatus = 'partial'; $resultReason = 'Compliance notification actions skipped'; @@ -486,7 +500,8 @@ public function execute( 'source' => 'restore', 'restore_run_id' => $restoreRun->id, 'backup_item_id' => $item->id, - ] + ], + assignments: $restoredAssignments, ); } } @@ -1365,6 +1380,11 @@ private function createAutopilotDeploymentProfileIfMissing( $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile') ?? 'deviceManagement/windowsAutopilotDeploymentProfiles'; $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload); + $payload['displayName'] = $this->prefixRestoredName( + $this->resolvePayloadString($payload, ['displayName', 'name']), + $policyId + ); + unset($payload['name']); if ($payload === []) { return [ @@ -1463,7 +1483,7 @@ private function buildSettingsCatalogCreatePayload( $payload = []; $name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']); - $payload['name'] = $name ?? sprintf('Restored %s', $fallbackName); + $payload['name'] = $this->prefixRestoredName($name, $fallbackName); $description = $this->resolvePayloadString($originalPayload, ['description', 'Description']); if ($description !== null) { @@ -1505,6 +1525,24 @@ private function buildSettingsCatalogCreatePayload( return $payload; } + private function prefixRestoredName(?string $name, string $fallback): string + { + $prefix = 'Restored_'; + $base = trim((string) ($name ?? $fallback)); + + if ($base === '') { + $base = $fallback; + } + + $normalized = strtolower($base); + + if (str_starts_with($normalized, 'restored_') || str_starts_with($normalized, 'restored ')) { + return $base; + } + + return $prefix.$base; + } + /** * @param array $payload * @param array $keys diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 214fcb6..8186f53 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -91,7 +91,15 @@ public function captureFromGraph( if ($includeAssignments) { try { - $rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $tenantIdentifier, $policy->external_id, $graphOptions); + $rawAssignments = $this->assignmentFetcher->fetch( + $policy->policy_type, + $tenantIdentifier, + $policy->external_id, + $graphOptions, + true + ); + $assignmentMetadata['assignments_fetched'] = true; + $assignmentMetadata['assignments_count'] = count($rawAssignments); if (! empty($rawAssignments)) { $resolvedGroups = []; @@ -110,14 +118,8 @@ public function captureFromGraph( $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) ->contains(fn (array $group) => $group['orphaned'] ?? false); - $assignmentMetadata['assignments_count'] = count($rawAssignments); - $filterIds = collect($rawAssignments) - ->pluck('target.deviceAndAppManagementAssignmentFilterId') - ->filter() - ->unique() - ->values() - ->all(); + $filterIds = $this->extractAssignmentFilterIds($rawAssignments); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filterNames = collect($filters) @@ -170,9 +172,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; } - $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; - if ($filterId && isset($filterNames[$filterId])) { - $target['assignment_filter_name'] = $filterNames[$filterId]; + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null); + $filterType = $assignment['deviceAndAppManagementAssignmentFilterType'] + ?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null); + + if ($filterId) { + $target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId; + + if ($filterType) { + $target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType; + } + + if (isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } } $assignment['target'] = $target; @@ -181,6 +195,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil }, $assignments); } + /** + * @param array> $assignments + * @return array + */ + private function extractAssignmentFilterIds(array $assignments): array + { + $filterIds = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $filterId = $assignment['deviceAndAppManagementAssignmentFilterId'] + ?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null); + + if (is_string($filterId) && $filterId !== '') { + $filterIds[] = $filterId; + } + } + + return array_values(array_unique($filterIds)); + } + /** * @param array $scopeTagIds * @return array{ids:array,names:array} diff --git a/config/graph_contracts.php b/config/graph_contracts.php index d637985..b181301 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -196,6 +196,7 @@ 'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', 'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', 'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', @@ -215,6 +216,7 @@ 'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign', 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', 'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', 'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', @@ -234,6 +236,7 @@ 'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign', 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceHealthScriptAssignments', 'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_update_method' => 'PATCH', 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', @@ -251,6 +254,10 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', @@ -290,6 +297,10 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], 'endpointSecurityIntent' => [ 'resource' => 'deviceManagement/intents', diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index fb6238b..f466235 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -118,9 +118,29 @@

Assignments

-

- Assignments were not captured for this version. -

+ @php + $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; + $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; + $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; + @endphp + @if($assignmentsFetchFailed) +

+ Assignments could not be fetched from Microsoft Graph. +

+ @if($assignmentsFetchError) +

+ {{ $assignmentsFetchError }} +

+ @endif + @elseif($assignmentsFetched) +

+ No assignments found for this version. +

+ @else +

+ Assignments were not captured for this version. +

+ @endif @php $hasBackupItem = $version->policy->backupItems() ->whereNotNull('assignments') diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index ab6fa32..e5a8981 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -42,9 +42,9 @@ ## Phase 3: Restore Logic and Mapping **Purpose**: Restore new policy types safely using assignment and foundation mappings. - [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. -- [ ] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. -- [ ] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. -- [ ] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. +- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. +- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. +- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. **Checkpoint**: Restore applies policies and assignments or skips with clear reasons. diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index 896b6f3..e619c15 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -19,7 +19,7 @@ // Mock PolicySnapshotService $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { $mock->shouldReceive('fetch') - ->twice() // Called once for each policy + ->once() // Called once for the active policy ->andReturnUsing(function ($tenant, $policy) { return [ 'payload' => [ @@ -96,6 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap 'display_name' => 'Policy B', 'platform' => 'windows', 'last_synced_at' => now(), + 'ignored_at' => now(), ]); $user = User::factory()->create(); @@ -109,15 +110,15 @@ public function request(string $method, string $path, array $options = []): Grap 'ownerRecord' => $backupSet, 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, ])->callTableAction('addPolicies', data: [ - 'policy_ids' => [$policyA->id, $policyB->id], + 'policy_ids' => [$policyA->id], 'include_assignments' => false, 'include_scope_tags' => true, ]); $backupSet->refresh(); - expect($backupSet->item_count)->toBe(2); - expect($backupSet->items)->toHaveCount(2); + expect($backupSet->item_count)->toBe(1); + expect($backupSet->items)->toHaveCount(1); expect($backupSet->items->first()->payload['id'])->toBe('policy-1'); $firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id); @@ -140,3 +141,61 @@ public function request(string $method, string $path, array $options = []): Grap 'resource_id' => (string) $backupSet->id, ]); }); + +test('backup service skips ignored policies', function () { + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturnUsing(function ($tenant, $policy) { + return [ + 'payload' => [ + 'id' => $policy->external_id, + 'name' => $policy->display_name, + 'roleScopeTagIds' => ['0'], + ], + 'metadata' => [], + 'warnings' => [], + ]; + }); + }); + + $tenant = Tenant::create([ + 'name' => 'Test tenant', + 'external_id' => 'tenant-1', + 'tenant_id' => 'tenant-1', + 'status' => 'active', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policyA = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Policy A', + 'platform' => 'windows', + 'last_synced_at' => now(), + ]); + + $policyB = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Policy B', + 'platform' => 'windows', + 'last_synced_at' => now(), + 'ignored_at' => now(), + ]); + + $service = app(\App\Services\Intune\BackupService::class); + $backupSet = $service->createBackupSet( + tenant: $tenant, + policyIds: [$policyA->id, $policyB->id], + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($backupSet->item_count)->toBe(1); + expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]); +}); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index b9bf0d0..706e2a0 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -292,6 +292,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon public int $createCalls = 0; + public array $createPayloads = []; + public function listPolicies(string $policyType, array $options = []): GraphResponse { return new GraphResponse(true, []); @@ -326,6 +328,7 @@ public function request(string $method, string $path, array $options = []): Grap { if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) { $this->createCalls++; + $this->createPayloads[] = $options['json'] ?? []; return new GraphResponse(true, ['id' => 'autopilot-created']); } @@ -384,6 +387,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($graphClient->applyCalls)->toBe(1); expect($graphClient->getCalls)->toBe(1); expect($graphClient->createCalls)->toBe(1); + expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile'); expect($run->status)->toBe('completed'); expect($run->results[0]['status'])->toBe('applied'); expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index 0699485..a3ce170 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -22,6 +22,14 @@ 'display_name' => 'Policy Display', 'platform' => 'windows', ]); + $ignoredPolicy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-ignored', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Ignored Policy', + 'platform' => 'windows', + 'ignored_at' => now(), + ]); $backupSet = BackupSet::factory()->for($tenant)->create([ 'item_count' => 2, @@ -39,6 +47,18 @@ ]) ->create(); + BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => $ignoredPolicy->id, + 'policy_identifier' => $ignoredPolicy->external_id, + 'policy_type' => $ignoredPolicy->policy_type, + 'platform' => $ignoredPolicy->platform, + 'payload' => ['id' => $ignoredPolicy->external_id], + ]) + ->create(); + BackupItem::factory() ->for($tenant) ->for($backupSet) @@ -65,6 +85,7 @@ 'backup_set_id' => $backupSet->id, ]) ->assertSee('Policy Display') + ->assertDontSee('Ignored Policy') ->assertSee('Scope Tag Alpha') ->assertSee('Settings Catalog Policy') ->assertSee('Scope Tag') diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 24a7a79..0570dad 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -536,7 +536,9 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requestCalls[1]['path'])->toBe('deviceManagement/configurationPolicies'); expect($client->requestCalls[1]['payload'])->toHaveKey('settings'); expect($client->requestCalls[1]['payload'])->toHaveKey('name'); + expect($client->requestCalls[1]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon'); expect($client->requestCalls[2]['path'])->toBe('deviceManagement/configurationPolicies'); expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings'); expect($client->requestCalls[2]['payload'])->toHaveKey('name'); + expect($client->requestCalls[2]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon'); }); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index 239ff72..af5de47 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -92,3 +92,23 @@ $response->assertOk(); $response->assertSee('Assignments were not captured for this version'); }); + +it('shows empty assignments message when assignments were fetched', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'assignments' => null, + 'metadata' => [ + 'assignments_fetched' => true, + 'assignments_count' => 0, + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('No assignments found for this version'); +}); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 459ef23..2a1ebfb 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -98,6 +98,70 @@ expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); +it('hydrates assignment filter names when filter data is stored at root', function () { + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'test-policy-id', + 'name' => 'Test Policy', + 'settings' => [], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version->assignments)->not->toBeNull() + ->and($version->assignments)->toHaveCount(1) + ->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123') + ->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') + ->and($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); + it('captures policy version without assignments when none exist', function () { // Mock dependencies $this->mock(PolicySnapshotService::class, function ($mock) { @@ -127,7 +191,9 @@ expect($version)->not->toBeNull() ->and($version->assignments)->toBeNull() - ->and($version->assignments_hash)->toBeNull(); + ->and($version->assignments_hash)->toBeNull() + ->and($version->metadata['assignments_fetched'])->toBeTrue() + ->and($version->metadata['assignments_count'])->toBe(0); }); it('handles assignment fetch failure gracefully', function () { diff --git a/tests/Unit/AssignmentBackupServiceTest.php b/tests/Unit/AssignmentBackupServiceTest.php new file mode 100644 index 0000000..d66a058 --- /dev/null +++ b/tests/Unit/AssignmentBackupServiceTest.php @@ -0,0 +1,93 @@ +create([ + 'tenant_id' => 'tenant-123', + 'external_id' => 'tenant-123', + ]); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'metadata' => [], + 'assignments' => null, + ]); + + $policyPayload = [ + 'roleScopeTagIds' => ['0'], + ]; + + $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['filter-123'], $tenant) + ->andReturn([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['0'], $tenant) + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + $service = app(AssignmentBackupService::class); + $updated = $service->enrichWithAssignments( + backupItem: $backupItem, + tenant: $tenant, + policyType: 'settingsCatalogPolicy', + policyId: 'policy-123', + policyPayload: $policyPayload, + includeAssignments: true + ); + + expect($updated->assignments)->toHaveCount(1) + ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123') + ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') + ->and($updated->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index bab434a..2626ab1 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -94,7 +94,7 @@ $this->graphClient ->shouldReceive('request') - ->once() + ->twice() ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); @@ -167,3 +167,30 @@ expect($result)->toBe([]); }); + +test('throws when both endpoints fail with throwOnFailure enabled', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; + + $failureResponse = new GraphResponse( + success: false, + data: [], + status: 403, + errors: [['message' => 'Forbidden']] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) + ->andReturn($failureResponse); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) + ->andReturn($failureResponse); + + $this->fetcher->fetch($policyType, $tenantId, $policyId, [], true); +})->throws(GraphException::class); diff --git a/tests/Unit/AssignmentRestoreServiceTest.php b/tests/Unit/AssignmentRestoreServiceTest.php new file mode 100644 index 0000000..1d0b96b --- /dev/null +++ b/tests/Unit/AssignmentRestoreServiceTest.php @@ -0,0 +1,202 @@ +set('graph_contracts.types.deviceManagementScript', [ + 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceManagementScriptAssignments', + ]); + config()->set('graph_contracts.types.settingsCatalogPolicy', [ + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + ]); + + $this->graphClient = Mockery::mock(GraphClientInterface::class); + $this->auditLogger = Mockery::mock(AuditLogger::class); + $this->filterResolver = Mockery::mock(AssignmentFilterResolver::class); + $this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault(); + + $this->service = new AssignmentRestoreService( + $this->graphClient, + app(GraphContractRegistry::class), + app(GraphLogger::class), + $this->auditLogger, + $this->filterResolver, + ); +}); + +it('uses the contract assignment payload key for assign actions', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-123'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + $expectedAssignments = [ + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/deviceManagementScripts/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['deviceManagementScriptAssignments'] ?? null) === $expectedAssignments + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'deviceManagementScript', + $policyId, + $assignments, + [] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); + +it('maps assignment filter ids stored at the root of assignments', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-789'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-source', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + $expectedAssignments = [ + [ + 'deviceAndAppManagementAssignmentFilterId' => 'filter-target', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['assignments'] ?? null) === $expectedAssignments + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'settingsCatalogPolicy', + $policyId, + $assignments, + [], + [ + 'assignmentFilter' => [ + 'filter-source' => 'filter-target', + ], + ] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); + +it('keeps assignment filters when mapping is missing but filter exists in target', function () { + $tenant = Tenant::factory()->make([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => null, + 'app_client_secret' => null, + ]); + $policyId = 'policy-999'; + $assignments = [ + [ + 'id' => 'assignment-1', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + ], + ]; + + $this->filterResolver + ->shouldReceive('resolve') + ->once() + ->with(['filter-1'], $tenant) + ->andReturn([['id' => 'filter-1', 'displayName' => 'Test']]); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on( + fn (array $options) => ($options['json']['assignments'][0]['deviceAndAppManagementAssignmentFilterId'] ?? null) === 'filter-1' + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'settingsCatalogPolicy', + $policyId, + $assignments, + [], + [] + ); + + expect($result['summary']['success'])->toBe(1); + expect($result['summary']['failed'])->toBe(0); + expect($result['summary']['skipped'])->toBe(0); +}); -- 2.45.2 From a985bff2870af9a81df1688484f995d03e7e7fff Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 16:04:08 +0100 Subject: [PATCH 06/14] fix: create missing policies on restore --- app/Services/Intune/RestoreService.php | 242 +++++++++++++++++- specs/007-device-config-compliance/tasks.md | 2 +- .../Feature/Filament/RestoreExecutionTest.php | 102 ++++++++ 3 files changed, 341 insertions(+), 5 deletions(-) diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index c5a36a1..7735824 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -242,6 +242,7 @@ public function execute( 'client_secret' => $tenant->app_client_secret, 'platform' => $item->platform, ]; + $updateMethod = $this->resolveUpdateMethod($item->policy_type); $settingsApply = null; $itemStatus = 'applied'; @@ -249,6 +250,7 @@ public function execute( $resultReason = null; $createdPolicyId = null; $createdPolicyMode = null; + $settingsApplyEligible = false; if ($item->policy_type === 'settingsCatalogPolicy') { $settings = $this->extractSettingsCatalogSettings($originalPayload); @@ -258,10 +260,55 @@ public function execute( $item->policy_type, $item->policy_identifier, $policyPayload, - $graphOptions + $graphOptions + ['method' => $updateMethod] ); - if ($response->successful() && $settings !== []) { + $settingsApplyEligible = $response->successful(); + + if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + $createOutcome = $this->createSettingsCatalogPolicy( + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + fallbackName: $item->resolvedDisplayName(), + ); + + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = $createOutcome['mode'] ?? null; + $mode = $createOutcome['mode'] ?? 'settings'; + + $itemStatus = $mode === 'settings' ? 'applied' : 'partial'; + $resultReason = $mode === 'metadata_only' + ? 'Policy missing; created metadata-only policy. Manual settings apply required.' + : 'Policy missing; created new policy with settings.'; + + if ($settings !== []) { + $settingsApply = $mode === 'metadata_only' + ? [ + 'total' => count($settings), + 'applied' => 0, + 'failed' => 0, + 'manual_required' => count($settings), + 'issues' => [], + ] + : [ + 'total' => count($settings), + 'applied' => count($settings), + 'failed' => 0, + 'manual_required' => 0, + 'issues' => [], + ]; + } + + $settingsApplyEligible = false; + } + } + + if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( policyId: $item->policy_identifier, settings: $settings, @@ -314,7 +361,7 @@ public function execute( ]; } } - } elseif ($settings !== []) { + } elseif ($settingsApplyEligible && $settings !== []) { $settingsApply = [ 'total' => count($settings), 'applied' => 0, @@ -328,7 +375,7 @@ public function execute( $item->policy_type, $item->policy_identifier, $payload, - $graphOptions + $graphOptions + ['method' => $updateMethod] ); if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { @@ -349,6 +396,26 @@ public function execute( $resultReason = 'Policy missing; created new Autopilot profile.'; } } + } elseif ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + $createOutcome = $this->createPolicyFromSnapshot( + policyType: $item->policy_type, + payload: $payload, + originalPayload: $originalPayload, + graphOptions: $graphOptions, + context: $context, + fallbackName: $item->resolvedDisplayName(), + ); + + if ($createOutcome['attempted']) { + $response = $createOutcome['response'] ?? $response; + + if ($createOutcome['success']) { + $createdPolicyId = $createOutcome['policy_id']; + $createdPolicyMode = 'created'; + $itemStatus = 'applied'; + $resultReason = 'Policy missing; created new policy.'; + } + } } } } catch (Throwable $throwable) { @@ -602,6 +669,52 @@ private function resolveRestoreMode(string $policyType): string return $restore; } + private function resolveUpdateMethod(string $policyType): string + { + $contract = $this->contracts->get($policyType); + $method = strtoupper((string) ($contract['update_method'] ?? 'PATCH')); + + return $method !== '' ? $method : 'PATCH'; + } + + private function resolveCreateMethod(string $policyType): ?string + { + $contract = $this->contracts->get($policyType); + $method = strtoupper((string) ($contract['create_method'] ?? 'POST')); + + return $method !== '' ? $method : null; + } + + private function shouldAttemptPolicyCreate(string $policyType, object $response): bool + { + if (! $this->isNotFoundResponse($response)) { + return false; + } + + $resource = $this->contracts->resourcePath($policyType); + $method = $this->resolveCreateMethod($policyType); + + return is_string($resource) && $resource !== '' && $method !== null; + } + + private function isNotFoundResponse(object $response): bool + { + if (($response->status ?? null) === 404) { + return true; + } + + $code = strtolower((string) ($response->meta['error_code'] ?? '')); + $message = strtolower((string) ($response->meta['error_message'] ?? '')); + + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { + return true; + } + + return $message !== '' && (str_contains($message, 'not found') + || str_contains($message, 'resource not found') + || str_contains($message, 'does not exist')); + } + /** * @param array> $entries * @return array> @@ -1359,6 +1472,70 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} + */ + private function createPolicyFromSnapshot( + string $policyType, + array $payload, + array $originalPayload, + array $graphOptions, + array $context, + string $fallbackName, + ): array { + $resource = $this->contracts->resourcePath($policyType); + $method = $this->resolveCreateMethod($policyType); + + if (! is_string($resource) || $resource === '' || $method === null) { + return [ + 'attempted' => false, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $createPayload = Arr::except($payload, ['assignments']); + $createPayload = $this->applyOdataTypeForCreate($policyType, $createPayload, $originalPayload); + $createPayload = $this->applyRestoredNameToPayload($createPayload, $originalPayload, $fallbackName); + + if ($createPayload === []) { + return [ + 'attempted' => true, + 'success' => false, + 'policy_id' => null, + 'response' => null, + ]; + } + + $this->graphLogger->logRequest('create_policy', $context + [ + 'endpoint' => $resource, + 'method' => $method, + 'policy_type' => $policyType, + ]); + + $response = $this->graphClient->request( + $method, + $resource, + ['json' => $createPayload] + Arr::except($graphOptions, ['platform']) + ); + + $this->graphLogger->logResponse('create_policy', $response, $context + [ + 'endpoint' => $resource, + 'method' => $method, + 'policy_type' => $policyType, + ]); + + $policyId = $this->extractCreatedPolicyId($response); + + return [ + 'attempted' => true, + 'success' => $response->successful(), + 'policy_id' => $policyId, + 'response' => $response, + ]; + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ @@ -1525,6 +1702,63 @@ private function buildSettingsCatalogCreatePayload( return $payload; } + /** + * @param array $payload + * @param array $originalPayload + * @return array + */ + private function applyRestoredNameToPayload(array $payload, array $originalPayload, string $fallbackName): array + { + $displayName = $this->resolvePayloadString($payload, ['displayName']); + $name = $this->resolvePayloadString($payload, ['name']); + $originalDisplayName = $this->resolvePayloadString($originalPayload, ['displayName']); + $originalName = $this->resolvePayloadString($originalPayload, ['name']); + $baseName = $displayName ?? $originalDisplayName ?? $name ?? $originalName ?? $fallbackName; + $restoredName = $this->prefixRestoredName($baseName, $fallbackName); + + if (array_key_exists('displayName', $payload) || $originalDisplayName !== null || $displayName !== null) { + $payload['displayName'] = $restoredName; + + return $payload; + } + + if (array_key_exists('name', $payload) || $originalName !== null || $name !== null) { + $payload['name'] = $restoredName; + + return $payload; + } + + $payload['displayName'] = $restoredName; + + return $payload; + } + + /** + * @param array $payload + * @param array $originalPayload + * @return array + */ + private function applyOdataTypeForCreate(string $policyType, array $payload, array $originalPayload): array + { + if (array_key_exists('@odata.type', $payload)) { + return $payload; + } + + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + + if ($odataType === null) { + return $payload; + } + + if (! $this->contracts->matchesTypeFamily($policyType, $odataType)) { + return $payload; + } + + $payload['@odata.type'] = $odataType; + + return $payload; + } + private function prefixRestoredName(?string $name, string $fallback): string { $prefix = 'Restored_'; diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index e5a8981..36113d0 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -41,7 +41,7 @@ ## Phase 3: Restore Logic and Mapping **Purpose**: Restore new policy types safely using assignment and foundation mappings. -- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. +- [x] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts. - [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. - [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. - [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`. diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 706e2a0..7e873b7 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -392,3 +392,105 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($run->results[0]['status'])->toBe('applied'); expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); }); + +test('restore execution creates missing policy using contracts', function () { + $graphClient = new class implements GraphClientInterface + { + public int $applyCalls = 0; + + public int $createCalls = 0; + + public array $createPayloads = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(false, [], 404, [], [], [ + 'error_code' => 'ResourceNotFound', + 'error_message' => 'Resource not found.', + ]); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'POST' && $path === 'deviceManagement/deviceCompliancePolicies') { + $this->createCalls++; + $this->createPayloads[] = $options['json'] ?? []; + + return new GraphResponse(true, ['id' => 'compliance-created']); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $graphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-4', + 'name' => 'Tenant Four', + 'metadata' => [], + ]); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'compliance-1', + 'policy_type' => 'deviceCompliancePolicy', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'displayName' => 'Compliance Policy', + 'description' => 'Test policy', + ], + ]) + ->create(); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($graphClient->applyCalls)->toBe(1); + expect($graphClient->createCalls)->toBe(1); + expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Compliance Policy'); + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['created_policy_id'])->toBe('compliance-created'); +}); -- 2.45.2 From 426a59e00b41cbfa09000aaa5dedfe24f232fe53 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 16:23:33 +0100 Subject: [PATCH 07/14] fix: surface compliance mapping in restore UI --- .../entries/restore-preview.blade.php | 26 +++++++++++++++++ .../entries/restore-results.blade.php | 29 ++++++++++++++----- specs/007-device-config-compliance/tasks.md | 4 +-- tests/Feature/Filament/RestorePreviewTest.php | 1 + 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 4fb8987..ca25216 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -88,6 +88,32 @@ {{ $item['compliance_action_warning'] }}
@endif + + @if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary'])) + @php + $summary = $item['compliance_action_summary']; + $missingTemplates = $item['compliance_action_missing_templates'] ?? []; + $total = (int) ($summary['total'] ?? 0); + $missing = (int) ($summary['missing'] ?? 0); + @endphp + +
+ Compliance notifications: {{ $total }} total • {{ $missing }} missing +
+ + @if (! empty($missingTemplates) && is_array($missingTemplates)) +
+ Missing notification templates +
+ @foreach ($missingTemplates as $templateId) +
+ {{ $templateId }} +
+ @endforeach +
+
+ @endif + @endif @endforeach diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 6d288b9..38a6ce4 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -192,10 +192,10 @@ @if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary'])) @php $summary = $item['compliance_action_summary']; - $complianceOutcomes = $item['compliance_action_outcomes'] ?? []; - $complianceIssues = collect($complianceOutcomes) - ->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped') - ->values(); + $complianceOutcomes = is_array($item['compliance_action_outcomes'] ?? null) + ? $item['compliance_action_outcomes'] + : []; + $complianceEntries = collect($complianceOutcomes)->values(); @endphp
@@ -203,18 +203,26 @@ {{ (int) ($summary['skipped'] ?? 0) }} skipped
- @if ($complianceIssues->isNotEmpty()) + @if ($complianceEntries->isNotEmpty())
Compliance notification details
- @foreach ($complianceIssues as $outcome) + @foreach ($complianceEntries as $outcome) + @php + $outcomeStatus = $outcome['status'] ?? 'unknown'; + $outcomeColor = match ($outcomeStatus) { + 'mapped' => 'text-green-700 bg-green-100 border-green-200', + 'skipped' => 'text-amber-900 bg-amber-100 border-amber-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + @endphp
Template {{ $outcome['template_id'] ?? 'unknown' }}
- - skipped + + {{ $outcomeStatus }}
@if (! empty($outcome['rule_name'])) @@ -222,6 +230,11 @@ Rule: {{ $outcome['rule_name'] }}
@endif + @if (! empty($outcome['mapped_template_id'])) +
+ Mapped to: {{ $outcome['mapped_template_id'] }} +
+ @endif @if (! empty($outcome['reason']))
{{ $outcome['reason'] }} diff --git a/specs/007-device-config-compliance/tasks.md b/specs/007-device-config-compliance/tasks.md index 36113d0..a9f3c35 100644 --- a/specs/007-device-config-compliance/tasks.md +++ b/specs/007-device-config-compliance/tasks.md @@ -54,8 +54,8 @@ ## Phase 4: Admin UX **Purpose**: Surface restore and compliance details clearly in the UI. -- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings. -- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons. +- [x] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings. +- [x] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons. **Checkpoint**: Admins can see compliance related mapping results in preview and results. diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index fc441dc..a4ccb0a 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -188,4 +188,5 @@ public function request(string $method, string $path, array $options = []): Grap expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull(); expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1); + expect($policyPreview['compliance_action_missing_templates'] ?? [])->toContain('template-1'); }); -- 2.45.2 From 1b2892d60b0ef10a1685836cc2d81c04c78bfb22 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 17:14:18 +0100 Subject: [PATCH 08/14] fix: show compliance notifications in policy versions --- .../PolicyVersionAssignmentsWidget.php | 106 +++++++++++++++++- ...olicy-version-assignments-widget.blade.php | 42 +++++++ .../PolicyVersionViewAssignmentsTest.php | 32 ++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php index 6211e6c..4067708 100644 --- a/app/Livewire/PolicyVersionAssignmentsWidget.php +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -14,10 +14,114 @@ public function mount(PolicyVersion $version): void $this->version = $version; } - public function render() + public function render(): \Illuminate\Contracts\View\View { return view('livewire.policy-version-assignments-widget', [ 'version' => $this->version, + 'compliance' => $this->complianceNotifications(), ]); } + + /** + * @return array{total:int,templates:array,items:array} + */ + private function complianceNotifications(): array + { + if ($this->version->policy_type !== 'deviceCompliancePolicy') { + return [ + 'total' => 0, + 'templates' => [], + 'items' => [], + ]; + } + + $snapshot = $this->version->snapshot; + + if (! is_array($snapshot)) { + return [ + 'total' => 0, + 'templates' => [], + 'items' => [], + ]; + } + + $scheduled = $snapshot['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return [ + 'total' => 0, + 'templates' => [], + 'items' => [], + ]; + } + + $items = []; + $templateIds = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $ruleName = $rule['ruleName'] ?? null; + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + continue; + } + + foreach ($configs as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['actionType'] ?? null) !== 'notification') { + continue; + } + + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($templateKey === null) { + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + continue; + } + + $items[] = [ + 'rule_name' => is_string($ruleName) ? $ruleName : null, + 'template_id' => $templateId, + 'template_key' => $templateKey, + ]; + $templateIds[] = $templateId; + } + } + + return [ + 'total' => count($items), + 'templates' => array_values(array_unique($templateIds)), + 'items' => $items, + ]; + } + + private function resolveNotificationTemplateKey(array $config): ?string + { + if (array_key_exists('notificationTemplateId', $config)) { + return 'notificationTemplateId'; + } + + if (array_key_exists('notificationMessageTemplateId', $config)) { + return 'notificationMessageTemplateId'; + } + + return null; + } + + private function isEmptyGuid(string $value): bool + { + return strtolower($value) === '00000000-0000-0000-0000-000000000000'; + } } diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index f466235..780eb83 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -155,4 +155,46 @@
@endif + + @php + $complianceTotal = $compliance['total'] ?? 0; + $complianceTemplates = $compliance['templates'] ?? []; + @endphp + @if($complianceTotal > 0) +
+
+
+
+

+ Compliance notifications +

+

+ {{ $complianceTotal }} action(s) • {{ count($complianceTemplates) }} template(s) +

+
+
+
+
+
+ @foreach($compliance['items'] ?? [] as $item) + @php + $ruleName = $item['rule_name'] ?? null; + $templateId = $item['template_id'] ?? null; + @endphp +
+ + + {{ $ruleName ?: 'Unnamed rule' }} + + @if($templateId) + + Template: {{ $templateId }} + + @endif +
+ @endforeach +
+
+
+ @endif diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index af5de47..e8297a1 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -112,3 +112,35 @@ $response->assertOk(); $response->assertSee('No assignments found for this version'); }); + +it('shows compliance notifications when present', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceCompliancePolicy', + 'assignments' => null, + 'snapshot' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => 'Test rule', + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-123', + ], + ], + ], + ], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('Compliance notifications'); + $response->assertSee('Test rule'); + $response->assertSee('template-123'); +}); -- 2.45.2 From 07c0c0e8618b830d83b7ec72f3e2ff4eae030341 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 17:37:55 +0100 Subject: [PATCH 09/14] fix: hydrate compliance notifications in snapshots --- .../Intune/CompliancePolicyNormalizer.php | 1 + app/Services/Intune/PolicySnapshotService.php | 64 +++++++++++ tests/Unit/CompliancePolicyNormalizerTest.php | 10 ++ tests/Unit/PolicySnapshotServiceTest.php | 105 ++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 tests/Unit/PolicySnapshotServiceTest.php diff --git a/app/Services/Intune/CompliancePolicyNormalizer.php b/app/Services/Intune/CompliancePolicyNormalizer.php index be66042..a87ea65 100644 --- a/app/Services/Intune/CompliancePolicyNormalizer.php +++ b/app/Services/Intune/CompliancePolicyNormalizer.php @@ -282,6 +282,7 @@ private function ignoredKeys(): array 'settingCount', 'settingsCount', 'templateReference', + 'scheduledActionsForRule', ]; } diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 87c9d6c..6ad2a60 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -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. */ diff --git a/tests/Unit/CompliancePolicyNormalizerTest.php b/tests/Unit/CompliancePolicyNormalizerTest.php index a2c5bca..d788e4f 100644 --- a/tests/Unit/CompliancePolicyNormalizerTest.php +++ b/tests/Unit/CompliancePolicyNormalizerTest.php @@ -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'); }); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php new file mode 100644 index 0000000..eaa9a02 --- /dev/null +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -0,0 +1,105 @@ +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']); +}); -- 2.45.2 From 89b1dfb78e2ab7527c6f1dba0c8796ac3e85b0dd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 17:53:54 +0100 Subject: [PATCH 10/14] fix: expand compliance actions in snapshots --- app/Services/Intune/PolicySnapshotService.php | 18 ++++++++++++++++-- config/graph_contracts.php | 2 +- tests/Unit/PolicySnapshotServiceTest.php | 7 +++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 6ad2a60..5caa176 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -39,12 +39,18 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $this->graphLogger->logRequest('get_policy', $context); try { - $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, [ + $options = [ 'tenant' => $tenantIdentifier, 'client_id' => $tenant->app_client_id, 'client_secret' => $tenant->app_client_secret, 'platform' => $policy->platform, - ]); + ]; + + if ($policy->policy_type === 'deviceCompliancePolicy') { + $options['expand'] = ['scheduledActionsForRule']; + } + + $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options); } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); @@ -191,6 +197,14 @@ private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant */ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { + $existingActions = $payload['scheduledActionsForRule'] ?? null; + + if (is_array($existingActions) && $existingActions !== []) { + $metadata['compliance_actions_hydration'] = 'embedded'; + + return [$payload, $metadata]; + } + $path = sprintf('deviceManagement/deviceCompliancePolicies/%s/scheduledActionsForRule', urlencode($policyId)); $options = [ 'tenant' => $tenantIdentifier, diff --git a/config/graph_contracts.php b/config/graph_contracts.php index b181301..3e86656 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -15,7 +15,7 @@ 'deviceConfiguration' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], - 'allowed_expand' => [], + 'allowed_expand' => ['scheduledActionsForRule'], 'type_family' => [ '#microsoft.graph.deviceConfiguration', '#microsoft.graph.windows10CustomConfiguration', diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index eaa9a02..aaee4b0 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -22,7 +22,7 @@ public function listPolicies(string $policyType, array $options = []): GraphResp public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { - $this->requests[] = ['getPolicy', $policyType, $policyId]; + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; return new GraphResponse(success: true, data: [ 'payload' => [ @@ -101,5 +101,8 @@ public function request(string $method, string $path, array $options = []): Grap 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']); + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('deviceCompliancePolicy'); + expect($client->requests[0][2])->toBe('compliance-123'); + expect($client->requests[0][3]['expand'] ?? [])->toBe(['scheduledActionsForRule']); }); -- 2.45.2 From 59c74246d6e4111b89a7195ae58465ad0e3413c9 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 23:08:09 +0100 Subject: [PATCH 11/14] fix: allow nested compliance action expand --- app/Services/Graph/GraphContractRegistry.php | 31 +++++++++++++++---- app/Services/Intune/PolicySnapshotService.php | 2 +- config/graph_contracts.php | 5 ++- tests/Unit/PolicySnapshotServiceTest.php | 3 +- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 37afda0..abf23f0 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -25,22 +25,41 @@ public function sanitizeQuery(string $policyType, array $query): array $allowedExpand = $contract['allowed_expand'] ?? []; $warnings = []; - if (! empty($query['$select']) && is_array($query['$select'])) { + if (! empty($query['$select'])) { $original = $query['$select']; - $query['$select'] = array_values(array_intersect($original, $allowedSelect)); + $select = is_array($original) + ? $original + : array_map('trim', explode(',', (string) $original)); + $filtered = array_values(array_intersect($select, $allowedSelect)); - if (count($query['$select']) !== count($original)) { + if (count($filtered) !== count($select)) { $warnings[] = 'Trimmed unsupported $select fields for capability safety.'; } + + if ($filtered === []) { + unset($query['$select']); + } else { + $query['$select'] = implode(',', $filtered); + } } - if (! empty($query['$expand']) && is_array($query['$expand'])) { + if (! empty($query['$expand'])) { $original = $query['$expand']; - $query['$expand'] = array_values(array_intersect($original, $allowedExpand)); + $expand = is_array($original) + ? $original + : [trim((string) $original)]; + $expand = array_values(array_filter($expand, static fn ($value) => $value !== '')); + $filtered = array_values(array_intersect($expand, $allowedExpand)); - if (count($query['$expand']) !== count($original)) { + if (count($filtered) !== count($expand)) { $warnings[] = 'Trimmed unsupported $expand fields for capability safety.'; } + + if ($filtered === []) { + unset($query['$expand']); + } else { + $query['$expand'] = implode(',', $filtered); + } } return [ diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 5caa176..031c561 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -47,7 +47,7 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; if ($policy->policy_type === 'deviceCompliancePolicy') { - $options['expand'] = ['scheduledActionsForRule']; + $options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)'; } $response = $this->graphClient->getPolicy($policy->policy_type, $policy->external_id, $options); diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 3e86656..67d46eb 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -15,7 +15,10 @@ 'deviceConfiguration' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], - 'allowed_expand' => ['scheduledActionsForRule'], + 'allowed_expand' => [ + 'scheduledActionsForRule', + 'scheduledActionsForRule($expand=scheduledActionConfigurations)', + ], 'type_family' => [ '#microsoft.graph.deviceConfiguration', '#microsoft.graph.windows10CustomConfiguration', diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index aaee4b0..93cbed1 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -104,5 +104,6 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][0])->toBe('getPolicy'); expect($client->requests[0][1])->toBe('deviceCompliancePolicy'); expect($client->requests[0][2])->toBe('compliance-123'); - expect($client->requests[0][3]['expand'] ?? [])->toBe(['scheduledActionsForRule']); + expect($client->requests[0][3]['expand'] ?? null) + ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); -- 2.45.2 From 18cf22a7edfc5bfa2aa98dd15ed06012fdfaab63 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 23:14:39 +0100 Subject: [PATCH 12/14] fix: drop odata type from foundation selects --- config/graph_contracts.php | 6 +++--- tests/Unit/GraphContractRegistryTest.php | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 67d46eb..98143bb 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -331,7 +331,7 @@ ], 'assignmentFilter' => [ 'resource' => 'deviceManagement/assignmentFilters', - 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', '@odata.type', 'roleScopeTagIds'], + 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', 'roleScopeTagIds'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceAndAppManagementAssignmentFilter', @@ -348,7 +348,7 @@ ], 'roleScopeTag' => [ 'resource' => 'deviceManagement/roleScopeTags', - 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'isBuiltIn'], + 'allowed_select' => ['id', 'displayName', 'description', 'isBuiltIn'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.roleScopeTag', @@ -365,7 +365,7 @@ ], 'notificationMessageTemplate' => [ 'resource' => 'deviceManagement/notificationMessageTemplates', - 'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', '@odata.type', 'lastModifiedDateTime'], + 'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', 'lastModifiedDateTime'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.notificationMessageTemplate', diff --git a/tests/Unit/GraphContractRegistryTest.php b/tests/Unit/GraphContractRegistryTest.php index 8b35555..cab3469 100644 --- a/tests/Unit/GraphContractRegistryTest.php +++ b/tests/Unit/GraphContractRegistryTest.php @@ -42,8 +42,8 @@ $query = $result['query']; $warnings = $result['warnings']; - expect($query['$select'])->toBe(['id', 'displayName']); - expect($query['$expand'])->toBe(['assignments']); + expect($query['$select'])->toBe('id,displayName'); + expect($query['$expand'])->toBe('assignments'); expect($warnings)->not->toBeEmpty(); }); -- 2.45.2 From 02dc55b5d4f9677da2e95eb1668d7631fc33916f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sun, 28 Dec 2025 23:51:07 +0100 Subject: [PATCH 13/14] fix: allow compliance scheduled action expand --- config/graph_contracts.php | 12 ++++++------ .../Unit/GraphContractRegistryActualDataTest.php | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 98143bb..774fa8e 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -15,10 +15,7 @@ 'deviceConfiguration' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], - 'allowed_expand' => [ - 'scheduledActionsForRule', - 'scheduledActionsForRule($expand=scheduledActionConfigurations)', - ], + 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceConfiguration', '#microsoft.graph.windows10CustomConfiguration', @@ -139,7 +136,10 @@ 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], - 'allowed_expand' => [], + 'allowed_expand' => [ + 'scheduledActionsForRule', + 'scheduledActionsForRule($expand=scheduledActionConfigurations)', + ], 'type_family' => [ '#microsoft.graph.deviceCompliancePolicy', '#microsoft.graph.windows10CompliancePolicy', @@ -331,7 +331,7 @@ ], 'assignmentFilter' => [ 'resource' => 'deviceManagement/assignmentFilters', - 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', 'roleScopeTagIds'], + 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceAndAppManagementAssignmentFilter', diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index 813aecf..d032deb 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -93,3 +93,19 @@ expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled'); expect($sanitized)->not->toHaveKey('locale'); }); + +it('exposes compliance policy expand for scheduled actions', function () { + $contract = $this->registry->get('deviceCompliancePolicy'); + + expect($contract)->not->toBeEmpty(); + expect($contract['allowed_expand'] ?? []) + ->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)'); +}); + +it('omits role scope tags from assignment filter selects', function () { + $contract = $this->registry->get('assignmentFilter'); + + expect($contract)->not->toBeEmpty(); + expect($contract['allowed_select'] ?? []) + ->not->toContain('roleScopeTagIds'); +}); -- 2.45.2 From 48124e1fd043eb19e3fc34dc201643959517db9f Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 29 Dec 2025 03:04:45 +0100 Subject: [PATCH 14/14] fix: make policy version diffs readable --- Agents.md | 1 + .../Resources/PolicyVersionResource.php | 66 ++++++- .../Intune/CompliancePolicyNormalizer.php | 87 ++++++++- .../Intune/DefaultPolicyNormalizer.php | 40 +++- .../DeviceConfigurationPolicyNormalizer.php | 5 +- .../entries/normalized-diff.blade.php | 176 +++++++++++++++--- ...olicy-version-assignments-widget.blade.php | 168 +++++++---------- .../PolicyVersionViewAssignmentsTest.php | 55 ++++++ tests/Unit/CompliancePolicyNormalizerTest.php | 43 +++++ .../Unit/DefaultPolicyNormalizerDiffTest.php | 21 +++ 10 files changed, 528 insertions(+), 134 deletions(-) create mode 100644 tests/Unit/DefaultPolicyNormalizerDiffTest.php diff --git a/Agents.md b/Agents.md index 5b0f824..16f2830 100644 --- a/Agents.md +++ b/Agents.md @@ -365,6 +365,7 @@ ## Conventions - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Check for existing components to reuse before writing a new one. +- UI consistency: Prefer Filament components (``, infolist/table entries, etc.) over custom HTML/Tailwind for admin UI; only roll custom markup when Filament cannot express the UI. ## Verification Scripts - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 89cce02..bb3e19d 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -55,8 +55,9 @@ public static function infolist(Schema $schema): Schema ->columnSpanFull() ->tabs([ Tab::make('Normalized settings') + ->id('normalized-settings') ->schema([ - Infolists\Components\ViewEntry::make('normalized_settings') + Infolists\Components\ViewEntry::make('normalized_settings_catalog') ->view('filament.infolists.entries.normalized-settings') ->state(function (PolicyVersion $record) { $normalized = app(PolicyNormalizer::class)->normalize( @@ -69,15 +70,34 @@ public static function infolist(Schema $schema): Schema $normalized['record_id'] = (string) $record->getKey(); return $normalized; - }), + }) + ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'), + + Infolists\Components\ViewEntry::make('normalized_settings_standard') + ->view('filament.infolists.entries.policy-settings-standard') + ->state(function (PolicyVersion $record) { + $normalized = app(PolicyNormalizer::class)->normalize( + is_array($record->snapshot) ? $record->snapshot : [], + $record->policy_type ?? '', + $record->platform + ); + + $normalized['context'] = 'version'; + $normalized['record_id'] = (string) $record->getKey(); + + return $normalized; + }) + ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'), ]), Tab::make('Raw JSON') + ->id('raw-json') ->schema([ Infolists\Components\ViewEntry::make('snapshot_pretty') ->view('filament.infolists.entries.snapshot-json') ->state(fn (PolicyVersion $record) => $record->snapshot ?? []), ]), Tab::make('Diff') + ->id('diff') ->schema([ Infolists\Components\ViewEntry::make('normalized_diff') ->view('filament.infolists.entries.normalized-diff') @@ -93,8 +113,9 @@ public static function infolist(Schema $schema): Schema return $diff->compare($from, $to); }), - Infolists\Components\TextEntry::make('diff') - ->label('Diff JSON vs previous') + Infolists\Components\ViewEntry::make('diff_json') + ->label('Raw diff (advanced)') + ->view('filament.infolists.entries.snapshot-json') ->state(function (PolicyVersion $record) { $previous = $record->previous(); @@ -102,11 +123,38 @@ public static function infolist(Schema $schema): Schema return ['summary' => 'No previous version']; } - return app(VersionDiff::class) - ->compare($previous->snapshot ?? [], $record->snapshot ?? []); - }) - ->formatStateUsing(fn ($state) => json_encode($state ?? [], JSON_PRETTY_PRINT)) - ->copyable(), + $diff = app(VersionDiff::class)->compare( + $previous->snapshot ?? [], + $record->snapshot ?? [] + ); + + $filter = static fn (array $items): array => array_filter( + $items, + static fn (mixed $value, string $key): bool => ! str_contains($key, '@odata.context'), + ARRAY_FILTER_USE_BOTH + ); + + $added = $filter($diff['added'] ?? []); + $removed = $filter($diff['removed'] ?? []); + $changed = $filter($diff['changed'] ?? []); + + return [ + 'summary' => [ + 'added' => count($added), + 'removed' => count($removed), + 'changed' => count($changed), + 'message' => sprintf( + '%d added, %d removed, %d changed', + count($added), + count($removed), + count($changed) + ), + ], + 'added' => $added, + 'removed' => $removed, + 'changed' => $changed, + ]; + }), ]), ]), ]); diff --git a/app/Services/Intune/CompliancePolicyNormalizer.php b/app/Services/Intune/CompliancePolicyNormalizer.php index a87ea65..2bcd299 100644 --- a/app/Services/Intune/CompliancePolicyNormalizer.php +++ b/app/Services/Intune/CompliancePolicyNormalizer.php @@ -44,7 +44,12 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + $snapshot = $snapshot ?? []; + + $normalized = $this->normalize($snapshot, $policyType, $platform); + $flat = $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + + return array_merge($flat, $this->flattenComplianceNotificationsForDiff($snapshot)); } /** @@ -85,6 +90,85 @@ private function buildComplianceBlocks(array $snapshot): array return $blocks; } + /** + * @return array + */ + private function flattenComplianceNotificationsForDiff(array $snapshot): array + { + $scheduled = $snapshot['scheduledActionsForRule'] ?? null; + + if (! is_array($scheduled)) { + return []; + } + + $templateIds = []; + + foreach ($scheduled as $rule) { + if (! is_array($rule)) { + continue; + } + + $configs = $rule['scheduledActionConfigurations'] ?? null; + + if (! is_array($configs)) { + continue; + } + + foreach ($configs as $config) { + if (! is_array($config)) { + continue; + } + + if (($config['actionType'] ?? null) !== 'notification') { + continue; + } + + $templateKey = $this->resolveNotificationTemplateKey($config); + + if ($templateKey === null) { + continue; + } + + $templateId = $config[$templateKey] ?? null; + + if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) { + continue; + } + + $templateIds[] = $templateId; + } + } + + $templateIds = array_values(array_unique($templateIds)); + sort($templateIds); + + if ($templateIds === []) { + return []; + } + + return [ + 'Compliance notifications > Template IDs' => $templateIds, + ]; + } + + private function resolveNotificationTemplateKey(array $config): ?string + { + if (array_key_exists('notificationTemplateId', $config)) { + return 'notificationTemplateId'; + } + + if (array_key_exists('notificationMessageTemplateId', $config)) { + return 'notificationMessageTemplateId'; + } + + return null; + } + + private function isEmptyGuid(string $value): bool + { + return strtolower($value) === '00000000-0000-0000-0000-000000000000'; + } + /** * @return array{keys: array, labels?: array} */ @@ -282,6 +366,7 @@ private function ignoredKeys(): array 'settingCount', 'settingsCount', 'templateReference', + 'scheduledActionsForRule@odata.context', 'scheduledActionsForRule', ]; } diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php index 6cf7145..f890a10 100644 --- a/app/Services/Intune/DefaultPolicyNormalizer.php +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -106,23 +106,49 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->flattenNormalizedForDiff($normalized); + } + + /** + * Flatten an already normalized payload into key/value pairs for diffing. + * + * @param array{settings: array>, settings_table?: array} $normalized + * @return array + */ + public function flattenNormalizedForDiff(array $normalized): array + { $map = []; if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) { + $title = $normalized['settings_table']['title'] ?? 'Settings'; + $prefix = is_string($title) && $title !== '' ? $title.' > ' : ''; + foreach ($normalized['settings_table']['rows'] as $row) { if (! is_array($row)) { continue; } - $key = $row['path'] ?? $row['definition'] ?? 'entry'; + $key = $prefix.($row['path'] ?? $row['definition'] ?? 'entry'); $map[$key] = $row['value'] ?? null; } } - foreach ($normalized['settings'] as $block) { + foreach ($normalized['settings'] ?? [] as $block) { + if (! is_array($block)) { + continue; + } + + $title = $block['title'] ?? null; + $prefix = is_string($title) && $title !== '' ? $title.' > ' : ''; + if (($block['type'] ?? null) === 'table') { foreach ($block['rows'] ?? [] as $row) { - $key = $row['path'] ?? $row['label'] ?? 'entry'; + if (! is_array($row)) { + continue; + } + + $key = $prefix.($row['path'] ?? $row['label'] ?? 'entry'); $map[$key] = $row['value'] ?? null; } @@ -130,7 +156,11 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl } foreach ($block['entries'] ?? [] as $entry) { - $key = $entry['key'] ?? 'entry'; + if (! is_array($entry)) { + continue; + } + + $key = $prefix.($entry['key'] ?? 'entry'); $map[$key] = $entry['value'] ?? null; } } @@ -554,6 +584,8 @@ private function normalizeStandard(array $snapshot): array 'omaSettings', 'settings', 'settingsDelta', + 'scheduledActionsForRule', + 'scheduledActionsForRule@odata.context', ]; $filtered = Arr::except($snapshot, $metadataKeys); diff --git a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php index 5c88051..12574da 100644 --- a/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php +++ b/app/Services/Intune/DeviceConfigurationPolicyNormalizer.php @@ -46,7 +46,10 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor */ public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array { - return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform); + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); } /** diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index 42effc5..c57ebd1 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -1,35 +1,169 @@ @php $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []]; $summary = $diff['summary'] ?? []; + + $groupByBlock = static function (array $items): array { + $groups = []; + + foreach ($items as $path => $value) { + if (! is_string($path) || $path === '') { + continue; + } + + $parts = explode(' > ', $path, 2); + + if (count($parts) === 2) { + [$group, $label] = $parts; + } else { + $group = 'Other'; + $label = $path; + } + + $groups[$group][$label] = $value; + } + + ksort($groups); + + return $groups; + }; + + $stringify = static function (mixed $value): string { + if ($value === null) { + return '—'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; + }; + + $isExpandable = static function (mixed $value): bool { + if (is_array($value)) { + return true; + } + + return is_string($value) && strlen($value) > 160; + }; @endphp -
-
Normalized diff
-
- {{ $summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0) }} -
+
+ +
+ + {{ (int) ($summary['added'] ?? 0) }} added + + + {{ (int) ($summary['removed'] ?? 0) }} removed + + + {{ (int) ($summary['changed'] ?? 0) }} changed + +
+
- @foreach (['added' => 'Added', 'removed' => 'Removed', 'changed' => 'Changed'] as $key => $label) + @foreach (['changed' => ['label' => 'Changed', 'collapsed' => false], 'added' => ['label' => 'Added', 'collapsed' => true], 'removed' => ['label' => 'Removed', 'collapsed' => true]] as $key => $meta) @php $items = $diff[$key] ?? []; + $groups = $groupByBlock(is_array($items) ? $items : []); @endphp - @if (! empty($items)) -
-
{{ $label }}
-
    - @foreach ($items as $name => $value) -
  • - {{ $name }}: - @if (is_array($value)) -
    {{ json_encode($value, JSON_PRETTY_PRINT) }}
    - @else - {{ is_bool($value) ? ($value ? 'true' : 'false') : (string) $value }} - @endif -
  • + @if ($groups !== []) + +
    + @foreach ($groups as $group => $groupItems) +
    +
    +
    + {{ $group }} +
    + + {{ count($groupItems) }} + +
    + +
    + @foreach ($groupItems as $name => $value) +
    + @if ($key === 'changed' && is_array($value) && array_key_exists('from', $value) && array_key_exists('to', $value)) + @php + $from = $value['from']; + $to = $value['to']; + $fromText = $stringify($from); + $toText = $stringify($to); + @endphp +
    +
    + {{ (string) $name }} +
    +
    + From + @if ($isExpandable($from)) +
    + + View + +
    {{ $fromText }}
    +
    + @else +
    {{ $fromText }}
    + @endif +
    +
    + To + @if ($isExpandable($to)) +
    + + View + +
    {{ $toText }}
    +
    + @else +
    {{ $toText }}
    + @endif +
    +
    + @else + @php + $text = $stringify($value); + @endphp +
    +
    + {{ (string) $name }} +
    +
    + @if ($isExpandable($value)) +
    + + View + +
    {{ $text }}
    +
    + @else +
    {{ $text }}
    + @endif +
    +
    + @endif +
    + @endforeach +
    +
    @endforeach -
-
+
+ @endif @endforeach
diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 780eb83..fd8466a 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -1,22 +1,11 @@ -
+
@if($version->assignments && count($version->assignments) > 0) -
-
-
-
-

- Assignments -

-

- Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }} -

-
-
-
- -
- -
+ +
+

Summary

{{ count($version->assignments) }} assignment(s) @@ -29,12 +18,11 @@

- @php $scopeTags = $version->scope_tags['names'] ?? []; @endphp @if(!empty($scopeTags)) -
+

Scope Tags

@foreach($scopeTags as $tag) @@ -46,7 +34,6 @@
@endif -

Assignment Details

@@ -56,7 +43,7 @@ $type = $target['@odata.type'] ?? ''; $typeKey = strtolower((string) $type); $intent = $assignment['intent'] ?? 'apply'; - + $typeName = match (true) { str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group', str_contains($typeKey, 'groupassignmenttarget') => 'Include group', @@ -64,7 +51,7 @@ str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices', default => 'Unknown', }; - + $groupId = $target['groupId'] ?? null; $groupName = $target['group_display_name'] ?? null; $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); @@ -78,7 +65,7 @@
{{ $typeName }} - + @if($groupId) : @if($groupOrphaned) @@ -104,56 +91,52 @@ Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }} @endif - + ({{ $intent }})
@endforeach
-
+ @else -
-
-

- Assignments -

- @php - $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; - $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; - $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; - @endphp - @if($assignmentsFetchFailed) -

- Assignments could not be fetched from Microsoft Graph. -

- @if($assignmentsFetchError) -

- {{ $assignmentsFetchError }} -

- @endif - @elseif($assignmentsFetched) -

- No assignments found for this version. -

- @else -

- Assignments were not captured for this version. + + @php + $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; + $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; + $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; + @endphp + @if($assignmentsFetchFailed) +

+ Assignments could not be fetched from Microsoft Graph. +

+ @if($assignmentsFetchError) +

+ {{ $assignmentsFetchError }}

@endif - @php - $hasBackupItem = $version->policy->backupItems() - ->whereNotNull('assignments') - ->where('created_at', '<=', $version->captured_at) - ->exists(); - @endphp - @if($hasBackupItem) -

- 💡 Assignment data may be available in related backup items. -

- @endif -
-
+ @elseif($assignmentsFetched) +

+ No assignments found for this version. +

+ @else +

+ Assignments were not captured for this version. +

+ @endif + + @php + $hasBackupItem = $version->policy->backupItems() + ->whereNotNull('assignments') + ->where('created_at', '<=', $version->captured_at) + ->exists(); + @endphp + @if($hasBackupItem) +

+ 💡 Assignment data may be available in related backup items. +

+ @endif + @endif @php @@ -161,40 +144,29 @@ $complianceTemplates = $compliance['templates'] ?? []; @endphp @if($complianceTotal > 0) -
-
-
-
-

- Compliance notifications -

-

- {{ $complianceTotal }} action(s) • {{ count($complianceTemplates) }} template(s) -

-
-
-
-
-
- @foreach($compliance['items'] ?? [] as $item) - @php - $ruleName = $item['rule_name'] ?? null; - $templateId = $item['template_id'] ?? null; - @endphp -
- - - {{ $ruleName ?: 'Unnamed rule' }} + +
+ @foreach($compliance['items'] ?? [] as $item) + @php + $ruleName = $item['rule_name'] ?? null; + $templateId = $item['template_id'] ?? null; + @endphp +
+ + + {{ $ruleName ?: 'Default rule' }} + + @if($templateId) + + Template: {{ $templateId }} - @if($templateId) - - Template: {{ $templateId }} - - @endif -
- @endforeach -
+ @endif +
+ @endforeach
-
+ @endif
diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index e8297a1..ff174ad 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -144,3 +144,58 @@ $response->assertSee('Test rule'); $response->assertSee('template-123'); }); + +it('uses a default label when compliance rule name is missing', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceCompliancePolicy', + 'assignments' => null, + 'snapshot' => [ + 'scheduledActionsForRule' => [ + [ + 'ruleName' => null, + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-456', + ], + ], + ], + ], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('Compliance notifications'); + $response->assertSee('Default rule'); + $response->assertSee('template-456'); +}); + +it('renders structured normalized settings for compliance policy versions', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceCompliancePolicy', + 'platform' => 'all', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'passwordRequired' => true, + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}?tab=normalized-settings"); + + $response->assertOk(); + $response->assertSee('Password & Access'); + $response->assertSee('Password required'); + $response->assertSee('Enabled'); +}); diff --git a/tests/Unit/CompliancePolicyNormalizerTest.php b/tests/Unit/CompliancePolicyNormalizerTest.php index d788e4f..2ef9b53 100644 --- a/tests/Unit/CompliancePolicyNormalizerTest.php +++ b/tests/Unit/CompliancePolicyNormalizerTest.php @@ -23,6 +23,7 @@ ], ], ], + 'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies', 'customSetting' => 'Custom value', ]; @@ -41,6 +42,48 @@ ->toContain('Custom Setting'); expect(collect($additionalBlock['rows'])->pluck('label')->all()) ->not->toContain('Scheduled Actions For Rule'); + expect(collect($additionalBlock['rows'])->pluck('label')->all()) + ->not->toContain('Scheduled Actions For Rule@Odata.context'); expect($settings->pluck('title')->all())->not->toContain('General'); }); + +it('flattens compliance notifications into a compact diff key', function () { + $normalizer = app(CompliancePolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.windows10CompliancePolicy', + 'passwordRequired' => true, + 'scheduledActionsForRule' => [ + [ + 'ruleName' => null, + 'scheduledActionConfigurations' => [ + [ + 'actionType' => 'notification', + 'notificationTemplateId' => 'template-123', + ], + [ + 'actionType' => 'notification', + 'notificationTemplateId' => '00000000-0000-0000-0000-000000000000', + ], + [ + 'actionType' => 'block', + 'notificationTemplateId' => 'template-ignored', + ], + ], + ], + ], + 'scheduledActionsForRule@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/deviceCompliancePolicies', + ]; + + $flat = $normalizer->flattenForDiff($snapshot, 'deviceCompliancePolicy', 'windows'); + + expect($flat)->toHaveKey('Password & Access > Password required'); + expect($flat['Password & Access > Password required'])->toBeTrue(); + + expect($flat)->toHaveKey('Compliance notifications > Template IDs'); + expect($flat['Compliance notifications > Template IDs'])->toBe(['template-123']); + + expect(array_keys($flat))->not->toContain('scheduledActionsForRule'); + expect(array_keys($flat))->not->toContain('scheduledActionsForRule@odata.context'); +}); diff --git a/tests/Unit/DefaultPolicyNormalizerDiffTest.php b/tests/Unit/DefaultPolicyNormalizerDiffTest.php new file mode 100644 index 0000000..8774a84 --- /dev/null +++ b/tests/Unit/DefaultPolicyNormalizerDiffTest.php @@ -0,0 +1,21 @@ + '#microsoft.graph.somePolicy', + 'displayName' => 'Example Policy', + 'customSetting' => true, + ]; + + $flat = $normalizer->flattenForDiff($snapshot, 'somePolicyType', 'all'); + + expect($flat)->toHaveKey('General > Display Name', 'Example Policy'); + expect($flat)->toHaveKey('General > Custom Setting'); + expect($flat['General > Custom Setting'])->toBeTrue(); +}); -- 2.45.2