From bae341958672522274b68c0a2fbc7f4312ce1e39 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 29 Dec 2025 16:18:36 +0100 Subject: [PATCH] feat: appProtectionPolicy assignments + UI normalization --- 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 + ...AppProtectionPolicySettingsDisplayTest.php | 65 +++ .../AppProtectionPolicySyncFilteringTest.php | 100 +++++ .../AppProtectionPolicyNormalizerTest.php | 44 ++ tests/Unit/AssignmentFetcherTest.php | 33 ++ tests/Unit/AssignmentRestoreServiceTest.php | 53 +++ .../GraphContractRegistryActualDataTest.php | 13 + 16 files changed, 952 insertions(+), 24 deletions(-) create mode 100644 app/Services/Intune/AppProtectionPolicyNormalizer.php 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/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');