feat/009-app-protection-policy #11

Merged
ahmido merged 3 commits from feat/009-app-protection-policy into dev 2025-12-29 16:11:51 +00:00
19 changed files with 1062 additions and 24 deletions

View File

@ -5,6 +5,7 @@
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient; use App\Services\Graph\NullGraphClient;
use App\Services\Intune\AppProtectionPolicyNormalizer;
use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer;
use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer;
@ -33,6 +34,7 @@ public function register(): void
$this->app->tag( $this->app->tag(
[ [
AppProtectionPolicyNormalizer::class,
CompliancePolicyNormalizer::class, CompliancePolicyNormalizer::class,
DeviceConfigurationPolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class,

View File

@ -60,7 +60,14 @@ public function enrichWithAssignments(
// Fetch assignments from Graph API // Fetch assignments from Graph API
$graphOptions = $tenant->graphOptions(); $graphOptions = $tenant->graphOptions();
$tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id; $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)) { if (empty($assignments)) {
// No assignments or fetch failed // No assignments or fetch failed

View File

@ -39,6 +39,7 @@ public function restore(
?RestoreRun $restoreRun = null, ?RestoreRun $restoreRun = null,
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
?string $policyOdataType = null,
): array { ): array {
$outcomes = []; $outcomes = [];
$summary = [ $summary = [
@ -57,6 +58,15 @@ public function restore(
$contract = $this->contracts->get($policyType); $contract = $this->contracts->get($policyType);
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); $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'); $usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
$assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments'; $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 private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string
{ {
if (! is_string($template) || $template === '') { if (! is_string($template) || $template === '') {

View File

@ -24,7 +24,8 @@ public function fetch(
string $tenantId, string $tenantId,
string $policyId, string $policyId,
array $options = [], array $options = [],
bool $throwOnFailure = false bool $throwOnFailure = false,
?string $policyOdataType = null,
): array { ): array {
$contract = $this->contracts->get($policyType); $contract = $this->contracts->get($policyType);
$listPathTemplate = $contract['assignments_list_path'] ?? null; $listPathTemplate = $contract['assignments_list_path'] ?? null;
@ -38,22 +39,51 @@ public function fetch(
$primaryException = null; $primaryException = null;
$assignments = []; $assignments = [];
$primarySucceeded = false;
// Try primary endpoint // Try primary endpoint(s)
try { $listPathTemplates = [];
$assignments = $this->fetchPrimary(
$listPathTemplate, if ($policyType === 'appProtectionPolicy') {
$policyId, $derivedTemplate = $this->resolveAppProtectionAssignmentsListTemplate($policyOdataType);
$requestOptions,
$context, if ($derivedTemplate !== null) {
$throwOnFailure $listPathTemplates[] = $derivedTemplate;
); }
} catch (GraphException $e) {
$primaryException = $e;
} }
if (! empty($assignments)) { if (is_string($listPathTemplate) && $listPathTemplate !== '' && ! in_array($listPathTemplate, $listPathTemplates, true)) {
Log::debug('Fetched assignments via primary endpoint', [ $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, 'tenant_id' => $tenantId,
'policy_type' => $policyType, 'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
@ -92,6 +122,14 @@ public function fetch(
return []; return [];
} }
if ($policyType === 'appProtectionPolicy') {
if ($throwOnFailure && $primaryException) {
throw $primaryException;
}
return $assignments;
}
$fallbackException = null; $fallbackException = null;
try { try {
@ -141,6 +179,33 @@ public function fetch(
return []; 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. * Fetch assignments using primary endpoint.
*/ */

View File

@ -0,0 +1,406 @@
<?php
namespace App\Services\Intune;
use DateInterval;
use Illuminate\Support\Str;
class AppProtectionPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'appProtectionPolicy';
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>}
*/
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<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<int, string>, labels?: array<string, string>}
*/
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<string, mixed> $labels
* @return array<int, array<string, mixed>>
*/
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<int, string> $usedKeys
* @return array<int, array<string, mixed>>
*/
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<int, string>
*/
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<int, mixed> $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,
};
}
}

View File

@ -63,7 +63,8 @@ public function capture(
$tenantIdentifier, $tenantIdentifier,
$policy->external_id, $policy->external_id,
$graphOptions, $graphOptions,
true true,
$payload['@odata.type'] ?? null,
); );
$captureMetadata['assignments_fetched'] = true; $captureMetadata['assignments_fetched'] = true;
$captureMetadata['assignments_count'] = count($rawAssignments); $captureMetadata['assignments_count'] = count($rawAssignments);
@ -249,7 +250,8 @@ public function ensureVersionHasAssignments(
$tenantIdentifier, $tenantIdentifier,
$policy->external_id, $policy->external_id,
$graphOptions, $graphOptions,
true true,
is_array($version->snapshot) ? ($version->snapshot['@odata.type'] ?? null) : null,
); );
$metadata['assignments_fetched'] = true; $metadata['assignments_fetched'] = true;
$metadata['assignments_count'] = count($rawAssignments); $metadata['assignments_count'] = count($rawAssignments);

View File

@ -78,6 +78,21 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
continue; 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'; $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy';
$policyPlatform = $platform ?? ($policyData['platform'] ?? null); $policyPlatform = $platform ?? ($policyData['platform'] ?? null);

View File

@ -371,12 +371,32 @@ public function execute(
]; ];
} }
} else { } else {
$response = $this->graphClient->applyPolicy( if ($item->policy_type === 'appProtectionPolicy') {
$item->policy_type, $updatePath = $this->resolveAppProtectionPolicyUpdatePath(
$item->policy_identifier, policyId: $item->policy_identifier,
$payload, odataType: $this->resolvePayloadString($originalPayload, ['@odata.type']),
$graphOptions + ['method' => $updateMethod] );
);
$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()) { if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
$createOutcome = $this->createAutopilotDeploymentProfileIfMissing( $createOutcome = $this->createAutopilotDeploymentProfileIfMissing(
@ -467,6 +487,7 @@ public function execute(
restoreRun: $restoreRun, restoreRun: $restoreRun,
actorEmail: $actorEmail, actorEmail: $actorEmail,
actorName: $actorName, actorName: $actorName,
policyOdataType: $this->resolvePayloadString($originalPayload, ['@odata.type']),
); );
$assignmentSummary = $assignmentOutcomes['summary'] ?? null; $assignmentSummary = $assignmentOutcomes['summary'] ?? null;
@ -1507,6 +1528,15 @@ private function createPolicyFromSnapshot(
$resource = $this->contracts->resourcePath($policyType); $resource = $this->contracts->resourcePath($policyType);
$method = $this->resolveCreateMethod($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) { if (! is_string($resource) || $resource === '' || $method === null) {
return [ return [
'attempted' => false, 'attempted' => false,
@ -1798,6 +1828,44 @@ private function applyOdataTypeForCreate(string $policyType, array $payload, arr
return $payload; 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<string, mixed> $payload * @param array<string, mixed> $payload
* @param array<int, string> $keys * @param array<int, string> $keys

View File

@ -96,7 +96,8 @@ public function captureFromGraph(
$tenantIdentifier, $tenantIdentifier,
$policy->external_id, $policy->external_id,
$graphOptions, $graphOptions,
true true,
$payload['@odata.type'] ?? null,
); );
$assignmentMetadata['assignments_fetched'] = true; $assignmentMetadata['assignments_fetched'] = true;
$assignmentMetadata['assignments_count'] = count($rawAssignments); $assignmentMetadata['assignments_count'] = count($rawAssignments);

View File

@ -166,12 +166,29 @@
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'],
'allowed_expand' => [], 'allowed_expand' => [],
'type_family' => [ 'type_family' => [
'#microsoft.graph.managedAppPolicy',
'#microsoft.graph.targetedManagedAppProtection', '#microsoft.graph.targetedManagedAppProtection',
'#microsoft.graph.iosManagedAppProtection',
'#microsoft.graph.androidManagedAppProtection',
'#microsoft.graph.windowsInformationProtectionPolicy',
'#microsoft.graph.mdmWindowsInformationProtectionPolicy',
], ],
'create_method' => 'POST', 'create_method' => 'POST',
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', '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' => [ 'conditionalAccessPolicy' => [
'resource' => 'identity/conditionalAccess/policies', 'resource' => 'identity/conditionalAccess/policies',

View File

@ -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`

View File

@ -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 dont 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.

View File

@ -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`

View File

@ -0,0 +1,65 @@
<?php
use App\Filament\Resources\PolicyResource;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('policy detail shows app protection settings in readable sections', function () {
$tenant = Tenant::create([
'tenant_id' => 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');
});

View File

@ -0,0 +1,100 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\PolicySyncService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class FakeGraphClientForAppProtectionSync implements GraphClientInterface
{
/**
* @param array<string, GraphResponse> $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();
});

View File

@ -0,0 +1,44 @@
<?php
use App\Services\Intune\PolicyNormalizer;
use Tests\TestCase;
uses(TestCase::class);
test('app protection normalizer formats blocked/required booleans and durations', function () {
$normalizer = app(PolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#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');
});

View File

@ -42,6 +42,39 @@
expect($result)->toBe($assignments); 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 () { test('fallback on empty response', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$policyId = 'policy-456'; $policyId = 'policy-456';

View File

@ -23,6 +23,11 @@
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST', '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->graphClient = Mockery::mock(GraphClientInterface::class);
$this->auditLogger = Mockery::mock(AuditLogger::class); $this->auditLogger = Mockery::mock(AuditLogger::class);
@ -89,6 +94,54 @@
expect($result['summary']['skipped'])->toBe(0); 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 () { it('maps assignment filter ids stored at the root of assignments', function () {
$tenant = Tenant::factory()->make([ $tenant = Tenant::factory()->make([
'tenant_id' => 'tenant-123', 'tenant_id' => 'tenant-123',

View File

@ -117,6 +117,19 @@
expect($this->registry->matchesTypeFamily('mobileApp', '#microsoft.graph.iosVppApp'))->toBeTrue(); 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 () { it('omits role scope tags from assignment filter selects', function () {
$contract = $this->registry->get('assignmentFilter'); $contract = $this->registry->get('assignmentFilter');