From 47db966a19ba684ec6571c134c9f480d9d3342aa Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 29 Dec 2025 14:01:37 +0000 Subject: [PATCH] feat: add metadata-only mobile app coverage with scope tag restore (#10) Summary add mobileApp contract details (assignments, expanded type family, scope tag select) and spec/test coverage so App snapshots stay metadata-only yet still capture roleScopeTagIds. guard restores so scope tags are written back whenever a snapshot carries them, even without explicit foundation mappings, and document it via a new Filament restore test. keep existing restore/sync behaviors in place while ensuring mobileApp assignments and metadata continue to flow through the backup/restore pipeline. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/10 --- app/Services/Intune/PolicySnapshotService.php | 61 +++++++++++ app/Services/Intune/RestoreService.php | 25 ++++- config/graph_contracts.php | 41 ++++++- specs/008-apps-app-management/plan.md | 26 +++++ specs/008-apps-app-management/spec.md | 59 ++++++++++ specs/008-apps-app-management/tasks.md | 17 +++ .../Feature/Filament/RestoreExecutionTest.php | 101 ++++++++++++++++++ .../GraphContractRegistryActualDataTest.php | 15 +++ tests/Unit/PolicySnapshotServiceTest.php | 62 +++++++++++ 9 files changed, 404 insertions(+), 3 deletions(-) create mode 100644 specs/008-apps-app-management/plan.md create mode 100644 specs/008-apps-app-management/spec.md create mode 100644 specs/008-apps-app-management/tasks.md diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 031c561..55ccd51 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -46,6 +46,14 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null 'platform' => $policy->platform, ]; + if ($this->isMetadataOnlyPolicyType($policy->policy_type)) { + $select = $this->metadataOnlySelect($policy->policy_type); + + if ($select !== []) { + $options['select'] = $select; + } + } + if ($policy->policy_type === 'deviceCompliancePolicy') { $options['expand'] = 'scheduledActionsForRule($expand=scheduledActionConfigurations)'; } @@ -110,6 +118,10 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $metadataWarnings = $response->warnings ?? [$reason]; } + if (! $response->failed() && $this->isMetadataOnlyPolicyType($policy->policy_type)) { + $payload = $this->filterMetadataOnlyPayload($policy->policy_type, is_array($payload) ? $payload : []); + } + $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); @@ -130,6 +142,55 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + private function isMetadataOnlyPolicyType(string $policyType): bool + { + foreach (config('tenantpilot.supported_policy_types', []) as $type) { + if (($type['type'] ?? null) === $policyType) { + return ($type['backup'] ?? null) === 'metadata-only'; + } + } + + return false; + } + + /** + * @return array + */ + private function metadataOnlySelect(string $policyType): array + { + $contract = $this->contracts->get($policyType); + $allowedSelect = $contract['allowed_select'] ?? []; + + if (! is_array($allowedSelect)) { + return []; + } + + return array_values(array_filter( + $allowedSelect, + static fn (mixed $key) => is_string($key) && $key !== '@odata.type' + )); + } + + private function filterMetadataOnlyPayload(string $policyType, array $payload): array + { + $contract = $this->contracts->get($policyType); + $allowedSelect = $contract['allowed_select'] ?? []; + + if (! is_array($allowedSelect) || $allowedSelect === []) { + return $payload; + } + + $filtered = []; + + foreach ($allowedSelect as $key) { + if (is_string($key) && array_key_exists($key, $payload)) { + $filtered[$key] = $payload[$key]; + } + } + + return $filtered; + } + /** * Hydrate settings catalog policies with configuration settings subresource. * diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 04d729c..3b96dc0 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -794,11 +794,32 @@ private function applyScopeTagMapping(array $payload, array $scopeTagMapping): a */ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, array $scopeTagMapping): array { - if ($scopeTagIds === null || $scopeTagMapping === []) { + if ($scopeTagIds === null) { return $payload; } - $payload['roleScopeTagIds'] = array_values($scopeTagIds); + $mapped = []; + + foreach ($scopeTagIds as $id) { + if (! is_string($id) && ! is_int($id)) { + continue; + } + + $stringId = (string) $id; + + if ($stringId === '') { + continue; + } + + $mapped[] = $scopeTagMapping[$stringId] ?? $stringId; + } + + if ($mapped === []) { + return $payload; + } + + $payload['roleScopeTagIds'] = array_values(array_unique($mapped)); + unset($payload['RoleScopeTagIds']); return $payload; } diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 774fa8e..32d5684 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -319,15 +319,54 @@ ], 'mobileApp' => [ 'resource' => 'deviceAppManagement/mobileApps', - 'allowed_select' => ['id', 'displayName', 'publisher', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_select' => ['id', 'displayName', 'publisher', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.mobileApp', + '#microsoft.graph.androidLobApp', + '#microsoft.graph.androidStoreApp', + '#microsoft.graph.androidManagedStoreApp', + '#microsoft.graph.iosLobApp', + '#microsoft.graph.iosStoreApp', + '#microsoft.graph.iosVppApp', + '#microsoft.graph.winGetApp', + '#microsoft.graph.macOSLobApp', + '#microsoft.graph.macOSMicrosoftEdgeApp', + '#microsoft.graph.macOSMicrosoftDefenderApp', + '#microsoft.graph.macOSDmgApp', + '#microsoft.graph.macOSPkgApp', + '#microsoft.graph.macOsVppApp', + '#microsoft.graph.macOSWebClip', + '#microsoft.graph.managedAndroidLobApp', + '#microsoft.graph.managedAndroidStoreApp', + '#microsoft.graph.managedIOSLobApp', + '#microsoft.graph.managedIOSStoreApp', + '#microsoft.graph.microsoftStoreForBusinessApp', + '#microsoft.graph.officeSuiteApp', + '#microsoft.graph.macOSOfficeSuiteApp', + '#microsoft.graph.webApp', + '#microsoft.graph.windowsWebApp', + '#microsoft.graph.windowsAppX', + '#microsoft.graph.windowsUniversalAppX', + '#microsoft.graph.windowsMicrosoftEdgeApp', + '#microsoft.graph.windowsMobileMSI', + '#microsoft.graph.windowsPhone81AppXBundle', + '#microsoft.graph.windowsPhone81AppX', + '#microsoft.graph.windowsPhone81StoreApp', + '#microsoft.graph.windowsPhoneXAP', + '#microsoft.graph.windowsStoreApp', + '#microsoft.graph.win32LobApp', + '#microsoft.graph.win32CatalogApp', + '#microsoft.graph.iOSiPadOSWebClip', ], 'create_method' => 'POST', 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/mobileApps/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/mobileApps/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'mobileAppAssignments', ], 'assignmentFilter' => [ 'resource' => 'deviceManagement/assignmentFilters', diff --git a/specs/008-apps-app-management/plan.md b/specs/008-apps-app-management/plan.md new file mode 100644 index 0000000..41e8587 --- /dev/null +++ b/specs/008-apps-app-management/plan.md @@ -0,0 +1,26 @@ +# Implementation Plan: Apps (008) + +**Branch**: `feat/008-apps-app-management` +**Date**: 2025-12-29 +**Spec Source**: [spec.md](./spec.md) + +## Summary +Make `mobileApp` reliable in the existing Policy/Backup/Restore flows by: + +- Extending Graph contract registry with app assignment endpoints and a realistic `@odata.type` family. +- Enforcing metadata-only snapshots during capture (contract-driven filtering). +- Adding targeted Pest tests for contracts + snapshot behavior. + +## Execution Steps +1. Update `config/graph_contracts.php` for `mobileApp`: + - Add assignments list + assign action endpoints and payload key. + - Expand `type_family` using known app types. +2. Update `app/Services/Intune/PolicySnapshotService.php`: + - For metadata-only types, request/select and persist only metadata keys (even if Graph falls back). +3. Add/extend tests: + - `tests/Unit/GraphContractRegistryActualDataTest.php` for `mobileApp` contract coverage. + - `tests/Unit/PolicySnapshotServiceTest.php` for metadata-only filtering behavior. +4. Run formatting + tests: + - `./vendor/bin/pint --dirty` + - `./vendor/bin/sail artisan test tests/Unit/GraphContractRegistryActualDataTest.php tests/Unit/PolicySnapshotServiceTest.php` + diff --git a/specs/008-apps-app-management/spec.md b/specs/008-apps-app-management/spec.md new file mode 100644 index 0000000..f32e66b --- /dev/null +++ b/specs/008-apps-app-management/spec.md @@ -0,0 +1,59 @@ +# Feature Specification: Apps (Mobile Apps) Metadata-Only + Assignments + +**Feature Branch**: `feat/008-apps-app-management` +**Created**: 2025-12-29 +**Status**: Draft +**Input**: `.specify/spec.md` (mobileApp scope) + `references/IntuneManagement-master/Documentation/AppTypes.json` (known app @odata.type values). + +## Overview +Add reliable **mobile app** (`mobileApp`) coverage for inventory, backup/version capture, and restore: + +- Capture **metadata-only** snapshots for Intune apps (no content/binary workflows). +- Capture and restore **assignments** using the Intune `/assignments` and `/assign` endpoints. +- Accept common **derived `@odata.type`** values for apps to avoid false `odata_mismatch` failures. + +## In Scope +- Policy type: `mobileApp` (`deviceAppManagement/mobileApps`) in existing Policy/Backup/Restore flows. +- Backup/version capture stores only metadata fields + `@odata.type` (contract-driven). +- Restore updates metadata fields on existing apps and reapplies assignments (with group + assignment filter mapping). +- UI continues to use the existing Policies/Versions/Backup/Restore Filament experience. + +## Out of Scope (v1) +- Downloading or re-uploading app binaries/content (Win32 content versions, files, etc.). +- Creating complex missing apps from scratch (where Graph requires app-type-specific required fields). +- App relationships (dependencies/supersedence) beyond what is already present in metadata. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Inventory and detail view for apps (Priority: P1) +As an admin, I can see Intune apps in the Policies list and inspect app metadata and assignments safely. + +**Independent Test**: Sync policies; open Policies list filtered to “Applications”; open an app and verify metadata is readable and assignments are shown (when captured). + +**Acceptance Scenarios**: +1. Given apps exist in Intune, when the admin syncs and opens Policies, then apps appear under type `Applications (Metadata only)`. +2. Given an app is a derived type (e.g. Win32, iOS VPP), when viewed/restored, then it is considered in-family for `mobileApp` (no `odata_mismatch`). + +### User Story 2 - Backup capture for apps (Priority: P1) +As an admin, I can capture backups/versions of apps with metadata-only payloads and optional assignments. + +**Independent Test**: Create a backup with assignments enabled including at least one app; confirm the stored payload is metadata-only and assignments are captured. + +**Acceptance Scenarios**: +1. Given app backup is configured as metadata-only, when a snapshot is captured, then only whitelisted metadata keys are persisted. +2. Given assignments are enabled, when capturing, then assignments are fetched via the configured assignments endpoint and stored alongside the snapshot. + +### User Story 3 - Restore assignments for apps (Priority: P1) +As an admin, I can restore app assignments (and metadata where supported) using group mapping with clear skip/failure reasons. + +**Independent Test**: Restore an app backup into a tenant where group IDs differ; verify assignments are created/skipped with the expected outcomes. + +**Acceptance Scenarios**: +1. Given an app exists in the target tenant, when restore executes, then metadata fields are patched and assignments are applied via `/assign`. +2. Given required group mappings are missing, when restore executes, then assignments are skipped with a human readable reason per assignment. +3. Given the app does not exist (404), when restore executes, then the item fails with a clear reason (no attempt to create complex apps from metadata-only snapshots). + +## Notes +- `@odata.type` matching uses `config/graph_contracts.php` as the safety gate for restore execution. +- App type-family values are sourced from `references/IntuneManagement-master/Documentation/AppTypes.json` and kept conservative. + diff --git a/specs/008-apps-app-management/tasks.md b/specs/008-apps-app-management/tasks.md new file mode 100644 index 0000000..9b9a734 --- /dev/null +++ b/specs/008-apps-app-management/tasks.md @@ -0,0 +1,17 @@ +# Tasks: Apps (Mobile Apps) Metadata-Only + Assignments (008) + +**Branch**: `feat/008-apps-app-management` | **Date**: 2025-12-29 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts and Safety Gates +- [ ] T001 Expand `mobileApp` Graph contract in `config/graph_contracts.php` (assignments endpoints + payload key + type family). + +## Phase 2: Snapshot Capture (Metadata-Only) +- [ ] T002 Enforce metadata-only snapshot capture for apps in `app/Services/Intune/PolicySnapshotService.php`. + +## Phase 3: Tests and Verification +- [ ] T003 Add contract coverage tests in `tests/Unit/GraphContractRegistryActualDataTest.php`. +- [ ] T004 Add snapshot filtering tests in `tests/Unit/PolicySnapshotServiceTest.php`. +- [ ] T005 Run tests: `./vendor/bin/sail artisan test tests/Unit/GraphContractRegistryActualDataTest.php tests/Unit/PolicySnapshotServiceTest.php` +- [ ] T006 Run Pint: `./vendor/bin/pint --dirty` + diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 7e873b7..590049c 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -283,6 +283,107 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ]); }); +test('restore execution applies scope tags even without foundation mapping', function () { + $client = new class implements GraphClientInterface + { + public array $applied = []; + + 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->applied[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + 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, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-scope-tags', + 'name' => 'Tenant Scope Tags', + 'status' => 'active', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'app-1', + 'policy_type' => 'mobileApp', + 'display_name' => 'Mozilla Firefox', + 'platform' => 'all', + ]); + + $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' => [ + '@odata.type' => '#microsoft.graph.winGetApp', + 'displayName' => 'Mozilla Firefox', + 'roleScopeTagIds' => ['0', 'tag-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('completed'); + expect($client->applied)->toHaveCount(1); + expect($client->applied[0]['policyType'])->toBe('mobileApp'); + expect($client->applied[0]['policyId'])->toBe('app-1'); + expect($client->applied[0]['payload']['roleScopeTagIds'])->toBe(['0', 'tag-1']); +}); + test('restore execution creates an autopilot profile when missing', function () { $graphClient = new class implements GraphClientInterface { diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index d032deb..e54daa7 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -102,6 +102,21 @@ ->toContain('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); +it('exposes mobile app assignment endpoints and type family', function () { + $contract = $this->registry->get('mobileApp'); + + expect($contract)->not->toBeEmpty(); + expect($contract['allowed_select'] ?? [])->toContain('roleScopeTagIds'); + expect($contract['assignments_list_path'] ?? null) + ->toBe('/deviceAppManagement/mobileApps/{id}/assignments'); + expect($contract['assignments_create_path'] ?? null) + ->toBe('/deviceAppManagement/mobileApps/{id}/assign'); + expect($contract['assignments_payload_key'] ?? null) + ->toBe('mobileAppAssignments'); + expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.win32LobApp'))->toBeTrue(); + expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.iosVppApp'))->toBeTrue(); +}); + it('omits role scope tags from assignment filter selects', function () { $contract = $this->registry->get('assignmentFilter'); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 93cbed1..9fa44ae 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -24,6 +24,23 @@ public function getPolicy(string $policyType, string $policyId, array $options = { $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + if ($policyType === 'mobileApp') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Contoso Portal', + 'publisher' => 'Contoso', + 'description' => 'Company Portal', + '@odata.type' => '#microsoft.graph.win32LobApp', + 'createdDateTime' => '2025-01-01T00:00:00Z', + 'lastModifiedDateTime' => '2025-01-02T00:00:00Z', + 'roleScopeTagIds' => ['0', 'tag-1', 'tag-2'], + 'installCommandLine' => 'setup.exe /quiet', + 'largeIcon' => ['type' => 'image/png', 'value' => '...'], + ], + ]); + } + return new GraphResponse(success: true, data: [ 'payload' => [ 'id' => $policyId, @@ -107,3 +124,48 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['expand'] ?? null) ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); + +it('filters mobile app snapshots to metadata-only keys', function () { + $client = new PolicySnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-apps', + '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' => 'app-123', + 'policy_type' => 'mobileApp', + 'display_name' => 'Contoso Portal', + 'platform' => 'all', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result['payload'])->toHaveKeys([ + 'id', + 'displayName', + 'publisher', + 'description', + '@odata.type', + 'createdDateTime', + 'lastModifiedDateTime', + 'roleScopeTagIds', + ]); + expect($result['payload']['roleScopeTagIds'])->toBe(['0', 'tag-1', 'tag-2']); + expect($result['payload'])->not->toHaveKey('installCommandLine'); + expect($result['payload'])->not->toHaveKey('largeIcon'); + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('mobileApp'); + expect($client->requests[0][2])->toBe('app-123'); + expect($client->requests[0][3]['select'] ?? null)->toBeArray(); + expect($client->requests[0][3]['select'])->toContain('displayName'); + expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds'); + expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); +});