From fbb9748725e0315b43802aace628cc31993fe3e9 Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 29 Dec 2025 16:11:50 +0000 Subject: [PATCH] feat/009-app-protection-policy (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary add appProtectionPolicy coverage for assignments, normalize settings for UI, and skip targetedManagedAppConfiguration noise during inventory wire up derived Graph endpoints/contracts so restores use the correct /assign paths per platform and assignments no longer rely on unsupported $expand add normalization logic/tests plus Pact/Plan updates so capture+restore behave more like Intune’s app protection workflows and no longer expose unsupported fields Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/11 --- app/Providers/AppServiceProvider.php | 2 + app/Services/AssignmentBackupService.php | 9 +- app/Services/AssignmentRestoreService.php | 37 ++ app/Services/Graph/AssignmentFetcher.php | 93 +++- .../Intune/AppProtectionPolicyNormalizer.php | 406 ++++++++++++++++++ .../Intune/PolicyCaptureOrchestrator.php | 6 +- app/Services/Intune/PolicySyncService.php | 15 + app/Services/Intune/RestoreService.php | 80 +++- app/Services/Intune/VersionService.php | 3 +- config/graph_contracts.php | 17 + specs/009-app-protection-policy/plan.md | 30 ++ specs/009-app-protection-policy/spec.md | 57 +++ specs/009-app-protection-policy/tasks.md | 23 + ...AppProtectionPolicySettingsDisplayTest.php | 65 +++ .../AppProtectionPolicySyncFilteringTest.php | 100 +++++ .../AppProtectionPolicyNormalizerTest.php | 44 ++ tests/Unit/AssignmentFetcherTest.php | 33 ++ tests/Unit/AssignmentRestoreServiceTest.php | 53 +++ .../GraphContractRegistryActualDataTest.php | 13 + 19 files changed, 1062 insertions(+), 24 deletions(-) create mode 100644 app/Services/Intune/AppProtectionPolicyNormalizer.php create mode 100644 specs/009-app-protection-policy/plan.md create mode 100644 specs/009-app-protection-policy/spec.md create mode 100644 specs/009-app-protection-policy/tasks.md create mode 100644 tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php create mode 100644 tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php create mode 100644 tests/Unit/AppProtectionPolicyNormalizerTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3226a0e..e654c93 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,7 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; +use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; @@ -33,6 +34,7 @@ public function register(): void $this->app->tag( [ + AppProtectionPolicyNormalizer::class, CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index 9c3e941..8439de0 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -60,7 +60,14 @@ public function enrichWithAssignments( // Fetch assignments from Graph API $graphOptions = $tenant->graphOptions(); $tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id; - $assignments = $this->assignmentFetcher->fetch($policyType, $tenantId, $policyId, $graphOptions); + $assignments = $this->assignmentFetcher->fetch( + $policyType, + $tenantId, + $policyId, + $graphOptions, + false, + $policyPayload['@odata.type'] ?? null, + ); if (empty($assignments)) { // No assignments or fetch failed diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php index 980749c..2d8abee 100644 --- a/app/Services/AssignmentRestoreService.php +++ b/app/Services/AssignmentRestoreService.php @@ -39,6 +39,7 @@ public function restore( ?RestoreRun $restoreRun = null, ?string $actorEmail = null, ?string $actorName = null, + ?string $policyOdataType = null, ): array { $outcomes = []; $summary = [ @@ -57,6 +58,15 @@ public function restore( $contract = $this->contracts->get($policyType); $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); + + if ($policyType === 'appProtectionPolicy') { + $derivedAssignPath = $this->resolveAppProtectionAssignmentsCreatePath($policyId, $policyOdataType); + + if ($derivedAssignPath !== null) { + $createPath = $derivedAssignPath; + } + } + $usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign'); $assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments'; @@ -435,6 +445,33 @@ public function restore( ]; } + private function resolveAppProtectionAssignmentsCreatePath(string $policyId, ?string $odataType): ?string + { + $entitySet = $this->resolveAppProtectionEntitySet($odataType); + + if ($entitySet === null) { + return null; + } + + return $this->resolvePath("/deviceAppManagement/{$entitySet}/{id}/assign", $policyId); + } + + private function resolveAppProtectionEntitySet(?string $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + return match (strtolower($odataType)) { + '#microsoft.graph.androidmanagedappprotection' => 'androidManagedAppProtections', + '#microsoft.graph.iosmanagedappprotection' => 'iosManagedAppProtections', + '#microsoft.graph.windowsinformationprotectionpolicy' => 'windowsInformationProtectionPolicies', + '#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'mdmWindowsInformationProtectionPolicies', + '#microsoft.graph.targetedmanagedappprotection' => 'targetedManagedAppProtections', + default => null, + }; + } + private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string { if (! is_string($template) || $template === '') { diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index f7efa2d..6bd2139 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -24,7 +24,8 @@ public function fetch( string $tenantId, string $policyId, array $options = [], - bool $throwOnFailure = false + bool $throwOnFailure = false, + ?string $policyOdataType = null, ): array { $contract = $this->contracts->get($policyType); $listPathTemplate = $contract['assignments_list_path'] ?? null; @@ -38,22 +39,51 @@ public function fetch( $primaryException = null; $assignments = []; + $primarySucceeded = false; - // Try primary endpoint - try { - $assignments = $this->fetchPrimary( - $listPathTemplate, - $policyId, - $requestOptions, - $context, - $throwOnFailure - ); - } catch (GraphException $e) { - $primaryException = $e; + // Try primary endpoint(s) + $listPathTemplates = []; + + if ($policyType === 'appProtectionPolicy') { + $derivedTemplate = $this->resolveAppProtectionAssignmentsListTemplate($policyOdataType); + + if ($derivedTemplate !== null) { + $listPathTemplates[] = $derivedTemplate; + } } - if (! empty($assignments)) { - Log::debug('Fetched assignments via primary endpoint', [ + if (is_string($listPathTemplate) && $listPathTemplate !== '' && ! in_array($listPathTemplate, $listPathTemplates, true)) { + $listPathTemplates[] = $listPathTemplate; + } + + foreach ($listPathTemplates as $template) { + try { + $assignments = $this->fetchPrimary( + $template, + $policyId, + $requestOptions, + $context, + $throwOnFailure + ); + $primarySucceeded = true; + + if (! empty($assignments)) { + Log::debug('Fetched assignments via primary endpoint', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); + + return $assignments; + } + } catch (GraphException $e) { + $primaryException = $primaryException ?? $e; + } + } + + if ($primarySucceeded && $policyType === 'appProtectionPolicy') { + Log::debug('Assignments fetched via primary endpoint(s)', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, @@ -92,6 +122,14 @@ public function fetch( return []; } + if ($policyType === 'appProtectionPolicy') { + if ($throwOnFailure && $primaryException) { + throw $primaryException; + } + + return $assignments; + } + $fallbackException = null; try { @@ -141,6 +179,33 @@ public function fetch( return []; } + private function resolveAppProtectionAssignmentsListTemplate(?string $odataType): ?string + { + $entitySet = $this->resolveAppProtectionEntitySet($odataType); + + if ($entitySet === null) { + return null; + } + + return "/deviceAppManagement/{$entitySet}/{id}/assignments"; + } + + private function resolveAppProtectionEntitySet(?string $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + return match (strtolower($odataType)) { + '#microsoft.graph.androidmanagedappprotection' => 'androidManagedAppProtections', + '#microsoft.graph.iosmanagedappprotection' => 'iosManagedAppProtections', + '#microsoft.graph.windowsinformationprotectionpolicy' => 'windowsInformationProtectionPolicies', + '#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'mdmWindowsInformationProtectionPolicies', + '#microsoft.graph.targetedmanagedappprotection' => 'targetedManagedAppProtections', + default => null, + }; + } + /** * Fetch assignments using primary endpoint. */ diff --git a/app/Services/Intune/AppProtectionPolicyNormalizer.php b/app/Services/Intune/AppProtectionPolicyNormalizer.php new file mode 100644 index 0000000..29a80bc --- /dev/null +++ b/app/Services/Intune/AppProtectionPolicyNormalizer.php @@ -0,0 +1,406 @@ +>, 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->buildBlocks($snapshot) as $block) { + $normalized['settings'][] = $block; + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @return array> + */ + private function buildBlocks(array $snapshot): array + { + $blocks = []; + $groups = $this->groupedFields(); + $usedKeys = []; + + foreach ($groups as $title => $group) { + $rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []); + + if ($rows === []) { + continue; + } + + if ($title === 'Basics') { + $platformLabel = $this->platformLabelFromOdataType($snapshot['@odata.type'] ?? null); + + if ($platformLabel !== null) { + array_unshift($rows, [ + 'path' => '@odata.type', + 'label' => 'Platform', + 'value' => $platformLabel, + ]); + } + } + + $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 [ + 'Basics' => [ + 'keys' => [ + 'displayName', + 'description', + 'appGroupType', + 'isAssigned', + 'deployedAppCount', + ], + 'labels' => [ + 'displayName' => 'Name', + 'description' => 'Description', + 'appGroupType' => 'App group type', + 'isAssigned' => 'Assigned', + 'deployedAppCount' => 'Deployed app count', + ], + ], + 'Data Protection' => [ + 'keys' => [ + 'dataBackupBlocked', + 'printBlocked', + 'saveAsBlocked', + 'screenCaptureBlocked', + 'allowedInboundDataTransferSources', + 'allowedOutboundDataTransferDestinations', + 'allowedDataIngestionLocations', + 'allowedOutboundClipboardSharingLevel', + ], + 'labels' => [ + 'dataBackupBlocked' => 'Prevent backups', + 'printBlocked' => 'Printing org data', + 'saveAsBlocked' => 'Save copies of org data', + 'screenCaptureBlocked' => 'Screen capture', + 'allowedInboundDataTransferSources' => 'Receive data from other apps', + 'allowedOutboundDataTransferDestinations' => 'Send org data to other apps', + 'allowedDataIngestionLocations' => 'Allow users to open data from selected services', + 'allowedOutboundClipboardSharingLevel' => 'Restrict cut, copy, and paste', + ], + ], + 'Access Requirements' => [ + 'keys' => [ + 'pinRequired', + 'pinCharacterSet', + 'minimumPinLength', + 'simplePinBlocked', + 'maximumPinRetries', + 'fingerprintAndBiometricEnabled', + 'pinRequiredInsteadOfBiometricTimeout', + 'periodOnlineBeforeAccessCheck', + 'periodOfflineBeforeAccessCheck', + ], + 'labels' => [ + 'pinRequired' => 'PIN for access', + 'pinCharacterSet' => 'PIN type', + 'minimumPinLength' => 'Minimum PIN length', + 'simplePinBlocked' => 'Block simple PIN', + 'maximumPinRetries' => 'Max PIN attempts', + 'fingerprintAndBiometricEnabled' => 'Biometrics instead of PIN', + 'pinRequiredInsteadOfBiometricTimeout' => 'Override biometrics with PIN after timeout', + 'periodOnlineBeforeAccessCheck' => 'Recheck access requirements after', + 'periodOfflineBeforeAccessCheck' => 'Offline grace period (block access)', + ], + ], + 'Conditional Launch' => [ + 'keys' => [ + 'periodOfflineBeforeWipeIsEnforced', + 'appActionIfMaximumPinRetriesExceeded', + 'appActionIfDeviceLockNotSet', + 'appActionIfDeviceComplianceRequired', + 'maximumAllowedDeviceThreatLevel', + 'mobileThreatDefenseRemediationAction', + ], + 'labels' => [ + 'periodOfflineBeforeWipeIsEnforced' => 'Offline grace period (wipe data)', + 'appActionIfMaximumPinRetriesExceeded' => 'Action if max PIN retries exceeded', + 'appActionIfDeviceLockNotSet' => 'Action if device lock not set', + 'appActionIfDeviceComplianceRequired' => 'Action if device compliance required', + 'maximumAllowedDeviceThreatLevel' => 'Maximum allowed device threat level', + 'mobileThreatDefenseRemediationAction' => 'Threat defense remediation action', + ], + ], + ]; + } + + /** + * @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[] = [ + 'path' => $key, + 'label' => $labels[$key] ?? Str::headline($key), + 'value' => $this->formatValue($key, $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[] = [ + 'path' => $key, + 'label' => Str::headline($key), + 'value' => $this->formatValue($key, $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', + ]; + } + + private function formatValue(string $key, mixed $value): mixed + { + if (is_bool($value)) { + $normalized = strtolower($key); + + if (str_ends_with($normalized, 'blocked')) { + return $value ? 'Blocked' : 'Allowed'; + } + + if (str_ends_with($normalized, 'required')) { + return $value ? 'Required' : 'Not required'; + } + + if (str_contains($normalized, 'enabled')) { + return $value ? 'Enabled' : 'Disabled'; + } + + return $value ? 'Yes' : 'No'; + } + + if (is_array($value)) { + if ($value === []) { + return 'None'; + } + + if (array_is_list($value) && $this->isScalarList($value)) { + return implode(', ', array_map(fn (mixed $item) => $this->formatScalarListItem($item), $value)); + } + + return json_encode($value, JSON_PRETTY_PRINT); + } + + if (is_string($value)) { + $duration = $this->formatDuration($value); + + if ($duration !== null) { + return $duration; + } + + if ($value === '') { + return null; + } + + if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $value) === 1) { + return Str::headline($value); + } + + return $value; + } + + return $value; + } + + /** + * @param array $value + */ + private function isScalarList(array $value): bool + { + foreach ($value as $item) { + if (! is_string($item) && ! is_int($item) && ! is_float($item)) { + return false; + } + } + + return true; + } + + private function formatScalarListItem(mixed $value): string + { + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + if (! is_string($value)) { + return ''; + } + + if (preg_match('/^[a-zA-Z][a-zA-Z0-9]*$/', $value) === 1) { + return Str::headline($value); + } + + return $value; + } + + private function formatDuration(string $value): ?string + { + if (! preg_match('/^P[T0-9]/i', $value)) { + return null; + } + + try { + $interval = new DateInterval(strtoupper($value)); + } catch (\Throwable) { + return null; + } + + $parts = []; + + if ($interval->y) { + $parts[] = $interval->y.' '.Str::plural('year', $interval->y); + } + if ($interval->m) { + $parts[] = $interval->m.' '.Str::plural('month', $interval->m); + } + if ($interval->d) { + $parts[] = $interval->d.' '.Str::plural('day', $interval->d); + } + + if ($interval->h) { + $parts[] = $interval->h.' '.Str::plural('hour', $interval->h); + } + if ($interval->i) { + $parts[] = $interval->i.' '.Str::plural('minute', $interval->i); + } + if ($interval->s && $parts === []) { + $parts[] = $interval->s.' '.Str::plural('second', $interval->s); + } + + if ($parts === []) { + return null; + } + + return implode(' ', $parts); + } + + private function platformLabelFromOdataType(mixed $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + return match (strtolower($odataType)) { + '#microsoft.graph.androidmanagedappprotection' => 'Android', + '#microsoft.graph.iosmanagedappprotection' => 'iOS', + '#microsoft.graph.windowsinformationprotectionpolicy', + '#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'Windows', + default => null, + }; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index 63e6f27..c495b73 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -63,7 +63,8 @@ public function capture( $tenantIdentifier, $policy->external_id, $graphOptions, - true + true, + $payload['@odata.type'] ?? null, ); $captureMetadata['assignments_fetched'] = true; $captureMetadata['assignments_count'] = count($rawAssignments); @@ -249,7 +250,8 @@ public function ensureVersionHasAssignments( $tenantIdentifier, $policy->external_id, $graphOptions, - true + true, + is_array($version->snapshot) ? ($version->snapshot['@odata.type'] ?? null) : null, ); $metadata['assignments_fetched'] = true; $metadata['assignments_count'] = count($rawAssignments); diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 69cc514..3d4fc06 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -78,6 +78,21 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr continue; } + if ($policyType === 'appProtectionPolicy') { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + + if (is_string($odataType) && strtolower($odataType) === '#microsoft.graph.targetedmanagedappconfiguration') { + Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + continue; + } + } + $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy'; $policyPlatform = $platform ?? ($policyData['platform'] ?? null); diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 3b96dc0..eb86cb9 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -371,12 +371,32 @@ public function execute( ]; } } else { - $response = $this->graphClient->applyPolicy( - $item->policy_type, - $item->policy_identifier, - $payload, - $graphOptions + ['method' => $updateMethod] - ); + if ($item->policy_type === 'appProtectionPolicy') { + $updatePath = $this->resolveAppProtectionPolicyUpdatePath( + policyId: $item->policy_identifier, + odataType: $this->resolvePayloadString($originalPayload, ['@odata.type']), + ); + + $response = $updatePath + ? $this->graphClient->request( + $updateMethod, + $updatePath, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ) + : $this->graphClient->applyPolicy( + $item->policy_type, + $item->policy_identifier, + $payload, + $graphOptions + ['method' => $updateMethod] + ); + } else { + $response = $this->graphClient->applyPolicy( + $item->policy_type, + $item->policy_identifier, + $payload, + $graphOptions + ['method' => $updateMethod] + ); + } if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) { $createOutcome = $this->createAutopilotDeploymentProfileIfMissing( @@ -467,6 +487,7 @@ public function execute( restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, + policyOdataType: $this->resolvePayloadString($originalPayload, ['@odata.type']), ); $assignmentSummary = $assignmentOutcomes['summary'] ?? null; @@ -1507,6 +1528,15 @@ private function createPolicyFromSnapshot( $resource = $this->contracts->resourcePath($policyType); $method = $this->resolveCreateMethod($policyType); + if ($policyType === 'appProtectionPolicy') { + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + $derivedResource = $this->resolveAppProtectionPolicyResource($odataType); + + if ($derivedResource !== null) { + $resource = $derivedResource; + } + } + if (! is_string($resource) || $resource === '' || $method === null) { return [ 'attempted' => false, @@ -1798,6 +1828,44 @@ private function applyOdataTypeForCreate(string $policyType, array $payload, arr return $payload; } + private function resolveAppProtectionPolicyUpdatePath(string $policyId, ?string $odataType): ?string + { + $resource = $this->resolveAppProtectionPolicyResource($odataType); + + if ($resource === null) { + return null; + } + + return sprintf('%s/%s', rtrim($resource, '/'), urlencode($policyId)); + } + + private function resolveAppProtectionPolicyResource(?string $odataType): ?string + { + $entitySet = $this->resolveAppProtectionEntitySet($odataType); + + if ($entitySet === null) { + return null; + } + + return "deviceAppManagement/{$entitySet}"; + } + + private function resolveAppProtectionEntitySet(?string $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + return match (strtolower($odataType)) { + '#microsoft.graph.androidmanagedappprotection' => 'androidManagedAppProtections', + '#microsoft.graph.iosmanagedappprotection' => 'iosManagedAppProtections', + '#microsoft.graph.windowsinformationprotectionpolicy' => 'windowsInformationProtectionPolicies', + '#microsoft.graph.mdmwindowsinformationprotectionpolicy' => 'mdmWindowsInformationProtectionPolicies', + '#microsoft.graph.targetedmanagedappprotection' => 'targetedManagedAppProtections', + default => null, + }; + } + /** * @param array $payload * @param array $keys diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 8186f53..f1f300e 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -96,7 +96,8 @@ public function captureFromGraph( $tenantIdentifier, $policy->external_id, $graphOptions, - true + true, + $payload['@odata.type'] ?? null, ); $assignmentMetadata['assignments_fetched'] = true; $assignmentMetadata['assignments_count'] = count($rawAssignments); diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 32d5684..5ecefe6 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -166,12 +166,29 @@ 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_expand' => [], 'type_family' => [ + '#microsoft.graph.managedAppPolicy', '#microsoft.graph.targetedManagedAppProtection', + '#microsoft.graph.iosManagedAppProtection', + '#microsoft.graph.androidManagedAppProtection', + '#microsoft.graph.windowsInformationProtectionPolicy', + '#microsoft.graph.mdmWindowsInformationProtectionPolicy', ], 'create_method' => 'POST', 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'isAssigned', + 'deployedAppCount', + 'apps', + 'apps@odata.context', + 'protectedAppLockerFiles', + 'exemptAppLockerFiles', + ], + 'assignments_list_path' => '/deviceAppManagement/managedAppPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/managedAppPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', ], 'conditionalAccessPolicy' => [ 'resource' => 'identity/conditionalAccess/policies', diff --git a/specs/009-app-protection-policy/plan.md b/specs/009-app-protection-policy/plan.md new file mode 100644 index 0000000..21ceaf6 --- /dev/null +++ b/specs/009-app-protection-policy/plan.md @@ -0,0 +1,30 @@ +# Implementation Plan: App Protection Policy Type (009) + +**Branch**: `feat/009-app-protection-policy` +**Date**: 2025-12-29 +**Spec Source**: [spec.md](./spec.md) + +## Summary +Make `appProtectionPolicy` reliable by: + +- Filtering non-policy objects during sync (`targetedManagedAppConfiguration`). +- Adding Graph contract coverage for assignments + `@odata.type` family. +- Adding targeted Pest tests to lock in behavior. + +## Execution Steps +1. Update `config/graph_contracts.php` for `appProtectionPolicy`: + - Add assignments list + assign action endpoints (and payload key if needed). + - Expand `type_family` to the common App Protection `@odata.type` values. +2. Update `app/Services/Intune/PolicySyncService.php`: + - Skip `#microsoft.graph.targetedManagedAppConfiguration` entries when syncing `appProtectionPolicy`. +3. Fix restore endpoints for assignments + policy updates: + - Use derived endpoints (e.g. `/androidManagedAppProtections/{id}` and `/androidManagedAppProtections/{id}/assign`) based on `@odata.type`. +4. Add admin-friendly normalization: + - Add `AppProtectionPolicyNormalizer` for boolean/duration formatting and Intune-like sections. +5. Add/extend tests: + - `tests/Unit/GraphContractRegistryActualDataTest.php` for `appProtectionPolicy` contract coverage. + - `tests/Feature/Jobs/*` to assert sync filtering behavior. + - `tests/Unit/*` to assert normalizer output and endpoint resolution. +6. Run formatting + tests: + - `./vendor/bin/pint --dirty` + - `./vendor/bin/sail artisan test --filter=appProtectionPolicy` diff --git a/specs/009-app-protection-policy/spec.md b/specs/009-app-protection-policy/spec.md new file mode 100644 index 0000000..4ff2cf1 --- /dev/null +++ b/specs/009-app-protection-policy/spec.md @@ -0,0 +1,57 @@ +# Feature Specification: App Protection (MAM) Policy Type Coverage + +**Feature Branch**: `feat/009-app-protection-policy` +**Created**: 2025-12-29 +**Status**: Draft + +## Overview +Make **App Protection (MAM)** policies (`appProtectionPolicy`) reliable in TenantAtlas’ existing Policy/Backup/Restore flows by: + +- Preventing **non-policy objects** (Managed App Configurations) from being imported as policies during sync. +- Capturing and restoring **assignments** for `managedAppPolicies`. +- Expanding the accepted `@odata.type` family so restore/create flows don’t fail with false `odata_mismatch`. +- Improving **admin readability** by normalizing key settings (booleans/durations) into Intune-like sections. + +## In Scope +- Policy type: `appProtectionPolicy` (`deviceAppManagement/managedAppPolicies`) +- Policy sync: skip objects with `@odata.type == #microsoft.graph.targetedManagedAppConfiguration` +- Backup/version capture: capture assignments when enabled +- Restore: reapply assignments using `/assign` with group + assignment filter mapping (existing mapping UI) +- UI: normalize App Protection snapshots for readability (bool/duration formatting + grouped sections) + +## Out of Scope (v1) +- “Target apps” (`/targetApps`) workflows for App Protection objects (showing the actual app list like Intune). +- Full “create from scratch” for missing App Protection policies (beyond generic create fallback). +- Separately modeling App Configurations (`targetedManagedAppConfigurations`) as their own policy type. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Clean Inventory (P1) +As an admin, I want the App Protection policy list to only include actual protection policies (not app configurations), so inventory stays accurate. + +**Independent Test**: Run policy sync; confirm `targetedManagedAppConfiguration` objects do not appear as `appProtectionPolicy` records. + +**Acceptance Scenarios** +1. Given Graph returns mixed objects from `managedAppPolicies`, when sync runs, then items with `@odata.type == #microsoft.graph.targetedManagedAppConfiguration` are skipped. + +### User Story 2 — Backup assignments (P1) +As an admin, I can capture App Protection assignments during backup/version capture, so restore can reproduce targeting. + +**Independent Test**: Capture a backup set with assignments enabled; verify assignments are saved for App Protection policies. + +**Acceptance Scenarios** +1. Given assignments are enabled, when capturing an App Protection snapshot, then assignments are fetched via the configured assignments endpoint and stored on the version/item. + +### User Story 3 — Restore assignments (P1) +As an admin, I can restore App Protection assignments using group mapping with clear skip/failure reasons. + +**Independent Test**: Restore an App Protection backup into a tenant with different group IDs; verify assignments are created/skipped with expected outcomes. + +**Acceptance Scenarios** +1. Given group mapping is present, when restore executes, then assignments are applied via `/assign`. +2. Given group mapping is missing for a group, when restore executes, then that assignment is skipped with a clear reason. + +## Notes +- Filtering is implemented in code because Graph filtering does not reliably exclude `targetedManagedAppConfiguration` objects from the `managedAppPolicies` list response. +- `@odata.type` matching uses `config/graph_contracts.php` as the safety gate for create flows. +- Assignments restore uses derived endpoints (e.g. `/deviceAppManagement/androidManagedAppProtections/{id}/assign`) based on `@odata.type` for compatibility. diff --git a/specs/009-app-protection-policy/tasks.md b/specs/009-app-protection-policy/tasks.md new file mode 100644 index 0000000..842265e --- /dev/null +++ b/specs/009-app-protection-policy/tasks.md @@ -0,0 +1,23 @@ +# Tasks: App Protection Policy Type Coverage (009) + +**Branch**: `feat/009-app-protection-policy` | **Date**: 2025-12-29 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts +- [ ] T001 Add App Protection assignments endpoints + type family to `config/graph_contracts.php`. + +## Phase 2: Sync filtering +- [ ] T002 Filter out `#microsoft.graph.targetedManagedAppConfiguration` during `appProtectionPolicy` sync. + +## Phase 3: Restore endpoint compatibility +- [ ] T003 Resolve derived update/assign endpoints for App Protection based on `@odata.type`. + +## Phase 4: UI normalization +- [ ] T004 Add `AppProtectionPolicyNormalizer` (booleans/durations + grouped sections). + +## Phase 5: Tests + Verification +- [ ] T005 Add contract coverage tests in `tests/Unit/GraphContractRegistryActualDataTest.php`. +- [ ] T006 Add sync filtering test in `tests/Feature/Jobs/*`. +- [ ] T007 Add unit tests for derived endpoint resolution + normalizer output. +- [ ] T008 Run tests (targeted): `./vendor/bin/sail artisan test --filter=appProtectionPolicy` +- [ ] T009 Run Pint: `./vendor/bin/pint --dirty` diff --git a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php new file mode 100644 index 0000000..c214e10 --- /dev/null +++ b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php @@ -0,0 +1,65 @@ + env('INTUNE_TENANT_ID', 'local-tenant'), + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'appProtectionPolicy', + 'display_name' => 'Teams', + 'platform' => 'mobile', + ]); + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.androidManagedAppProtection', + 'displayName' => 'Teams', + 'dataBackupBlocked' => false, + 'pinRequired' => true, + 'periodOnlineBeforeAccessCheck' => 'PT30M', + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Data Protection'); + $response->assertSee('Prevent backups'); + $response->assertSee('Allowed'); + $response->assertSee('Platform'); + $response->assertSee('Android'); + $response->assertSee('Access Requirements'); + $response->assertSee('PIN for access'); + $response->assertSee('Required'); + $response->assertSee('Recheck access requirements after'); + $response->assertSee('30 minutes'); +}); diff --git a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php new file mode 100644 index 0000000..8b4a56f --- /dev/null +++ b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php @@ -0,0 +1,100 @@ + $responses + */ + public function __construct(private array $responses = []) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return $this->responses[$policyType] ?? new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + 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, []); + } +} + +test('sync skips managed app configurations from app protection inventory', function () { + $tenant = Tenant::create([ + 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'name' => 'Test Tenant', + 'metadata' => [], + 'is_current' => true, + ]); + + Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'config-1', + 'policy_type' => 'appProtectionPolicy', + 'display_name' => 'Config 1 (legacy)', + 'platform' => 'mobile', + 'ignored_at' => null, + ]); + + $responses = [ + 'appProtectionPolicy' => new GraphResponse(true, [ + [ + 'id' => 'config-1', + 'displayName' => 'Config 1', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + [ + 'id' => 'config-skip', + 'displayName' => 'Config skip', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + [ + 'id' => 'policy-2', + 'displayName' => 'MAM Policy', + '@odata.type' => '#microsoft.graph.iosManagedAppProtection', + ], + ]), + ]; + + app()->instance(GraphClientInterface::class, new FakeGraphClientForAppProtectionSync($responses)); + + app(PolicySyncService::class)->syncPolicies($tenant); + + $existingConfig = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'appProtectionPolicy') + ->where('external_id', 'config-1') + ->firstOrFail(); + + expect($existingConfig->ignored_at)->not->toBeNull(); + expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'config-skip')->exists())->toBeFalse(); + expect(Policy::where('tenant_id', $tenant->id)->where('external_id', 'policy-2')->whereNull('ignored_at')->exists())->toBeTrue(); +}); diff --git a/tests/Unit/AppProtectionPolicyNormalizerTest.php b/tests/Unit/AppProtectionPolicyNormalizerTest.php new file mode 100644 index 0000000..10fb030 --- /dev/null +++ b/tests/Unit/AppProtectionPolicyNormalizerTest.php @@ -0,0 +1,44 @@ + '#microsoft.graph.androidManagedAppProtection', + 'displayName' => 'Teams', + 'dataBackupBlocked' => false, + 'pinRequired' => true, + 'periodOnlineBeforeAccessCheck' => 'PT30M', + 'periodOfflineBeforeWipeIsEnforced' => 'P90D', + 'allowedDataIngestionLocations' => ['oneDriveForBusiness', 'sharePoint'], + ]; + + $normalized = $normalizer->normalize($snapshot, 'appProtectionPolicy', 'mobile'); + + $blocks = collect($normalized['settings'] ?? []); + + $basics = $blocks->firstWhere('title', 'Basics'); + expect($basics)->not->toBeNull(); + expect($basics['rows'][0]['label'] ?? null)->toBe('Platform'); + expect($basics['rows'][0]['value'] ?? null)->toBe('Android'); + + $dataProtection = $blocks->firstWhere('title', 'Data Protection'); + expect($dataProtection)->not->toBeNull(); + expect(collect($dataProtection['rows'] ?? [])->firstWhere('path', 'dataBackupBlocked')['value'] ?? null)->toBe('Allowed'); + expect(collect($dataProtection['rows'] ?? [])->firstWhere('path', 'allowedDataIngestionLocations')['value'] ?? null) + ->toBe('One Drive For Business, Share Point'); + + $access = $blocks->firstWhere('title', 'Access Requirements'); + expect($access)->not->toBeNull(); + expect(collect($access['rows'] ?? [])->firstWhere('path', 'pinRequired')['value'] ?? null)->toBe('Required'); + expect(collect($access['rows'] ?? [])->firstWhere('path', 'periodOnlineBeforeAccessCheck')['value'] ?? null)->toBe('30 minutes'); + + $conditional = $blocks->firstWhere('title', 'Conditional Launch'); + expect($conditional)->not->toBeNull(); + expect(collect($conditional['rows'] ?? [])->firstWhere('path', 'periodOfflineBeforeWipeIsEnforced')['value'] ?? null)->toBe('90 days'); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index 2626ab1..ce174ee 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -42,6 +42,39 @@ expect($result)->toBe($assignments); }); +test('app protection uses derived assignments list endpoint', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'appProtectionPolicy'; + $assignments = [ + ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], + ]; + + $response = new GraphResponse( + success: true, + data: ['value' => $assignments] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceAppManagement/androidManagedAppProtections/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($response); + + $result = $this->fetcher->fetch( + $policyType, + $tenantId, + $policyId, + [], + false, + '#microsoft.graph.androidManagedAppProtection' + ); + + expect($result)->toBe($assignments); +}); + test('fallback on empty response', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; diff --git a/tests/Unit/AssignmentRestoreServiceTest.php b/tests/Unit/AssignmentRestoreServiceTest.php index 1d0b96b..5b89bd8 100644 --- a/tests/Unit/AssignmentRestoreServiceTest.php +++ b/tests/Unit/AssignmentRestoreServiceTest.php @@ -23,6 +23,11 @@ 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', 'assignments_create_method' => 'POST', ]); + config()->set('graph_contracts.types.appProtectionPolicy', [ + 'assignments_create_path' => '/deviceAppManagement/managedAppPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + ]); $this->graphClient = Mockery::mock(GraphClientInterface::class); $this->auditLogger = Mockery::mock(AuditLogger::class); @@ -89,6 +94,54 @@ expect($result['summary']['skipped'])->toBe(0); }); +it('uses derived assign endpoints for app protection policies', 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', + ], + ], + ]; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', "/deviceAppManagement/androidManagedAppProtections/{$policyId}/assign", Mockery::on( + fn (array $options) => isset($options['json']['assignments']) + )) + ->andReturn(new GraphResponse(success: true, data: [])); + + $this->auditLogger + ->shouldReceive('log') + ->once() + ->andReturn(new AuditLog); + + $result = $this->service->restore( + $tenant, + 'appProtectionPolicy', + $policyId, + $assignments, + [], + [], + null, + null, + null, + '#microsoft.graph.androidManagedAppProtection', + ); + + 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', diff --git a/tests/Unit/GraphContractRegistryActualDataTest.php b/tests/Unit/GraphContractRegistryActualDataTest.php index e54daa7..e61bf72 100644 --- a/tests/Unit/GraphContractRegistryActualDataTest.php +++ b/tests/Unit/GraphContractRegistryActualDataTest.php @@ -117,6 +117,19 @@ expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.iosVppApp'))->toBeTrue(); }); +it('exposes app protection assignment endpoints and type family', function () { + $contract = $this->registry->get('appProtectionPolicy'); + + expect($contract)->not->toBeEmpty(); + expect($contract['assignments_list_path'] ?? null) + ->toBe('/deviceAppManagement/managedAppPolicies/{id}/assignments'); + expect($contract['assignments_create_path'] ?? null) + ->toBe('/deviceAppManagement/managedAppPolicies/{id}/assign'); + expect($this->registry->matchesTypeFamily('appProtectionPolicy', '#microsoft.graph.iosManagedAppProtection'))->toBeTrue(); + expect($this->registry->matchesTypeFamily('appProtectionPolicy', '#microsoft.graph.androidManagedAppProtection'))->toBeTrue(); + expect($this->registry->matchesTypeFamily('appProtectionPolicy', '#microsoft.graph.targetedManagedAppConfiguration'))->toBeFalse(); +}); + it('omits role scope tags from assignment filter selects', function () { $contract = $this->registry->get('assignmentFilter');