feat/009-app-protection-policy (#11)
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 <ahmeddarrazi@adsmac.local> Reviewed-on: #11
This commit is contained in:
parent
47db966a19
commit
fbb9748725
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 === '') {
|
||||
|
||||
@ -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,19 +39,33 @@ public function fetch(
|
||||
|
||||
$primaryException = null;
|
||||
$assignments = [];
|
||||
$primarySucceeded = false;
|
||||
|
||||
// Try primary endpoint
|
||||
// Try primary endpoint(s)
|
||||
$listPathTemplates = [];
|
||||
|
||||
if ($policyType === 'appProtectionPolicy') {
|
||||
$derivedTemplate = $this->resolveAppProtectionAssignmentsListTemplate($policyOdataType);
|
||||
|
||||
if ($derivedTemplate !== null) {
|
||||
$listPathTemplates[] = $derivedTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($listPathTemplate) && $listPathTemplate !== '' && ! in_array($listPathTemplate, $listPathTemplates, true)) {
|
||||
$listPathTemplates[] = $listPathTemplate;
|
||||
}
|
||||
|
||||
foreach ($listPathTemplates as $template) {
|
||||
try {
|
||||
$assignments = $this->fetchPrimary(
|
||||
$listPathTemplate,
|
||||
$template,
|
||||
$policyId,
|
||||
$requestOptions,
|
||||
$context,
|
||||
$throwOnFailure
|
||||
);
|
||||
} catch (GraphException $e) {
|
||||
$primaryException = $e;
|
||||
}
|
||||
$primarySucceeded = true;
|
||||
|
||||
if (! empty($assignments)) {
|
||||
Log::debug('Fetched assignments via primary endpoint', [
|
||||
@ -62,6 +77,21 @@ public function fetch(
|
||||
|
||||
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,
|
||||
'count' => count($assignments),
|
||||
]);
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
// Try fallback with $expand
|
||||
Log::debug('Primary endpoint returned empty, trying fallback', [
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
406
app/Services/Intune/AppProtectionPolicyNormalizer.php
Normal file
406
app/Services/Intune/AppProtectionPolicyNormalizer.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -370,6 +370,25 @@ public function execute(
|
||||
'issues' => [],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
@ -377,6 +396,7 @@ public function execute(
|
||||
$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<string, mixed> $payload
|
||||
* @param array<int, string> $keys
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
30
specs/009-app-protection-policy/plan.md
Normal file
30
specs/009-app-protection-policy/plan.md
Normal 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`
|
||||
57
specs/009-app-protection-policy/spec.md
Normal file
57
specs/009-app-protection-policy/spec.md
Normal 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 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.
|
||||
23
specs/009-app-protection-policy/tasks.md
Normal file
23
specs/009-app-protection-policy/tasks.md
Normal 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`
|
||||
@ -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');
|
||||
});
|
||||
100
tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php
Normal file
100
tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php
Normal 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();
|
||||
});
|
||||
44
tests/Unit/AppProtectionPolicyNormalizerTest.php
Normal file
44
tests/Unit/AppProtectionPolicyNormalizerTest.php
Normal 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');
|
||||
});
|
||||
@ -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';
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user