feat/009-app-protection-policy #11
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 === '') {
|
||||||
|
|||||||
@ -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,19 +39,33 @@ public function fetch(
|
|||||||
|
|
||||||
$primaryException = null;
|
$primaryException = null;
|
||||||
$assignments = [];
|
$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 {
|
try {
|
||||||
$assignments = $this->fetchPrimary(
|
$assignments = $this->fetchPrimary(
|
||||||
$listPathTemplate,
|
$template,
|
||||||
$policyId,
|
$policyId,
|
||||||
$requestOptions,
|
$requestOptions,
|
||||||
$context,
|
$context,
|
||||||
$throwOnFailure
|
$throwOnFailure
|
||||||
);
|
);
|
||||||
} catch (GraphException $e) {
|
$primarySucceeded = true;
|
||||||
$primaryException = $e;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($assignments)) {
|
if (! empty($assignments)) {
|
||||||
Log::debug('Fetched assignments via primary endpoint', [
|
Log::debug('Fetched assignments via primary endpoint', [
|
||||||
@ -62,6 +77,21 @@ public function fetch(
|
|||||||
|
|
||||||
return $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,
|
||||||
|
'count' => count($assignments),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $assignments;
|
||||||
|
}
|
||||||
|
|
||||||
// Try fallback with $expand
|
// Try fallback with $expand
|
||||||
Log::debug('Primary endpoint returned empty, trying fallback', [
|
Log::debug('Primary endpoint returned empty, trying fallback', [
|
||||||
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
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,
|
$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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -370,6 +370,25 @@ public function execute(
|
|||||||
'issues' => [],
|
'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 {
|
} else {
|
||||||
$response = $this->graphClient->applyPolicy(
|
$response = $this->graphClient->applyPolicy(
|
||||||
$item->policy_type,
|
$item->policy_type,
|
||||||
@ -377,6 +396,7 @@ public function execute(
|
|||||||
$payload,
|
$payload,
|
||||||
$graphOptions + ['method' => $updateMethod]
|
$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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
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);
|
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';
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user