fix: improve assignment capture/restore and filter name handling (#8)

Resolves assignment filter names when Graph stores filter IDs at assignment root.
Tracks assignment fetch success/failure and shows clearer UI states for versions.
Adds scope tag fallback display in backup set items.
Restored versions now capture applied assignments consistently.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #8
This commit is contained in:
ahmido 2025-12-28 13:59:12 +00:00
parent d2dbc52a32
commit d939d45bcf
46 changed files with 3999 additions and 1040 deletions

View File

@ -52,16 +52,26 @@ public function table(Table $table): Table
->label('Assignments')
->badge()
->color('info')
->getStateUsing(function (BackupItem $record): int {
$assignments = $record->policyVersion?->assignments ?? $record->assignments ?? [];
->getStateUsing(function (BackupItem $record): string {
$assignments = $record->policyVersion?->assignments ?? $record->assignments;
return is_array($assignments) ? count($assignments) : 0;
if (is_array($assignments)) {
return (string) count($assignments);
}
$assignmentsFetched = $record->policyVersion?->metadata['assignments_fetched']
?? $record->metadata['assignments_fetched']
?? false;
return $assignmentsFetched ? '0' : '—';
}),
Tables\Columns\TextColumn::make('scope_tags')
->label('Scope Tags')
->default('—')
->getStateUsing(function (BackupItem $record): array {
$tags = $record->policyVersion?->scope_tags['names'] ?? [];
$tags = $record->policyVersion?->scope_tags['names']
?? $record->metadata['scope_tag_names']
?? [];
return is_array($tags) ? $tags : [];
})
@ -100,6 +110,7 @@ public function table(Table $table): Table
return Policy::query()
->where('tenant_id', $tenantId)
->whereNull('ignored_at')
->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround)
->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing))
->orderBy('display_name')

View File

@ -62,7 +62,7 @@ public static function infolist(Schema $schema): Schema
->columns(2)
->columnSpanFull(),
// For Settings Catalog policies: Tabs with Settings table + JSON viewer
// Tabbed content (General / Settings / JSON)
Tabs::make('policy_content')
->activeTab(1)
->persistTabInQueryString()
@ -74,10 +74,7 @@ public static function infolist(Schema $schema): Schema
->label('')
->view('filament.infolists.entries.policy-general')
->state(function (Policy $record) {
$normalized = static::normalizedPolicyState($record);
$split = static::splitGeneralBlock($normalized);
return $split['general'];
return static::generalOverviewState($record);
}),
])
->visible(fn (Policy $record) => $record->versions()->exists()),
@ -88,12 +85,9 @@ public static function infolist(Schema $schema): Schema
->label('')
->view('filament.infolists.entries.normalized-settings')
->state(function (Policy $record) {
$normalized = static::normalizedPolicyState($record);
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => $record->policy_type === 'settingsCatalogPolicy' &&
->visible(fn (Policy $record) => static::hasSettingsTable($record) &&
$record->versions()->exists()
),
@ -101,12 +95,9 @@ public static function infolist(Schema $schema): Schema
->label('')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (Policy $record) {
$normalized = static::normalizedPolicyState($record);
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
return static::settingsTabState($record);
})
->visible(fn (Policy $record) => $record->policy_type !== 'settingsCatalogPolicy' &&
->visible(fn (Policy $record) => ! static::hasSettingsTable($record) &&
$record->versions()->exists()
),
@ -144,12 +135,9 @@ public static function infolist(Schema $schema): Schema
->visible(fn (Policy $record) => $record->versions()->exists()),
])
->columnSpanFull()
->visible(function (Policy $record) {
return str_contains(strtolower($record->policy_type ?? ''), 'settings') ||
str_contains(strtolower($record->policy_type ?? ''), 'catalog');
}),
->visible(fn (Policy $record) => static::usesTabbedLayout($record)),
// For non-Settings Catalog policies: Simple sections without tabs
// Legacy layout (kept for fallback if tabs are disabled)
Section::make('Settings')
->schema([
ViewEntry::make('settings')
@ -170,9 +158,7 @@ public static function infolist(Schema $schema): Schema
])
->columnSpanFull()
->visible(function (Policy $record) {
// Show simple settings section for non-Settings Catalog policies
return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') &&
! str_contains(strtolower($record->policy_type ?? ''), 'catalog');
return ! static::usesTabbedLayout($record);
}),
Section::make('Policy Snapshot (JSON)')
@ -205,9 +191,7 @@ public static function infolist(Schema $schema): Schema
->description('Raw JSON configuration from Microsoft Graph API')
->columnSpanFull()
->visible(function (Policy $record) {
// Show standalone JSON section only for non-Settings Catalog policies
return ! str_contains(strtolower($record->policy_type ?? ''), 'settings') &&
! str_contains(strtolower($record->policy_type ?? ''), 'catalog');
return ! static::usesTabbedLayout($record);
}),
]);
}
@ -690,4 +674,101 @@ private static function typeMeta(?string $type): array
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
private static function usesTabbedLayout(Policy $record): bool
{
return true;
}
private static function hasSettingsTable(Policy $record): bool
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
return is_array($rows) && $rows !== [];
}
/**
* @return array{entries: array<int, array{key: string, value: mixed}>}
*/
private static function generalOverviewState(Policy $record): array
{
$snapshot = static::latestSnapshot($record);
$entries = [];
$name = $snapshot['displayName'] ?? $snapshot['name'] ?? $record->display_name;
if (is_string($name) && $name !== '') {
$entries[] = ['key' => 'Name', 'value' => $name];
}
$platforms = $snapshot['platforms'] ?? $snapshot['platform'] ?? $record->platform;
if (is_string($platforms) && $platforms !== '') {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
} elseif (is_array($platforms) && $platforms !== []) {
$entries[] = ['key' => 'Platforms', 'value' => $platforms];
}
$technologies = $snapshot['technologies'] ?? null;
if (is_string($technologies) && $technologies !== '') {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
} elseif (is_array($technologies) && $technologies !== []) {
$entries[] = ['key' => 'Technologies', 'value' => $technologies];
}
if (array_key_exists('templateReference', $snapshot)) {
$entries[] = ['key' => 'Template Reference', 'value' => $snapshot['templateReference']];
}
$settingCount = $snapshot['settingCount']
?? $snapshot['settingsCount']
?? (isset($snapshot['settings']) && is_array($snapshot['settings']) ? count($snapshot['settings']) : null);
if (is_int($settingCount) || is_numeric($settingCount)) {
$entries[] = ['key' => 'Setting Count', 'value' => $settingCount];
}
$version = $snapshot['version'] ?? null;
if (is_string($version) && $version !== '') {
$entries[] = ['key' => 'Version', 'value' => $version];
} elseif (is_numeric($version)) {
$entries[] = ['key' => 'Version', 'value' => $version];
}
$lastModified = $snapshot['lastModifiedDateTime'] ?? null;
if (is_string($lastModified) && $lastModified !== '') {
$entries[] = ['key' => 'Last Modified', 'value' => $lastModified];
}
$createdAt = $snapshot['createdDateTime'] ?? null;
if (is_string($createdAt) && $createdAt !== '') {
$entries[] = ['key' => 'Created', 'value' => $createdAt];
}
$description = $snapshot['description'] ?? null;
if (is_string($description) && $description !== '') {
$entries[] = ['key' => 'Description', 'value' => $description];
}
return [
'entries' => $entries,
];
}
/**
* @return array<string, mixed>
*/
private static function settingsTabState(Policy $record): array
{
$normalized = static::normalizedPolicyState($record);
$rows = $normalized['settings_table']['rows'] ?? [];
$hasSettingsTable = is_array($rows) && $rows !== [];
if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) {
$split = static::splitGeneralBlock($normalized);
return $split['normalized'];
}
return $normalized;
}
}

View File

@ -543,6 +543,11 @@ private static function restoreItemOptionData(?int $backupSetId): array
$items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
})
->with('policy:id,display_name')
->get()
->sortBy(function (BackupItem $item) {

View File

@ -51,6 +51,16 @@ public function handle(AssignmentBackupService $assignmentBackupService): void
return;
}
$tenant = $backupItem->tenant;
if ($tenant === null) {
Log::warning('FetchAssignmentsJob: Tenant not found for BackupItem', [
'backup_item_id' => $this->backupItemId,
]);
return;
}
// Only process Settings Catalog policies
if ($backupItem->policy_type !== 'settingsCatalogPolicy') {
Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [
@ -63,8 +73,9 @@ public function handle(AssignmentBackupService $assignmentBackupService): void
$assignmentBackupService->enrichWithAssignments(
backupItem: $backupItem,
tenantId: $this->tenantExternalId,
policyId: $this->policyExternalId,
tenant: $tenant,
policyType: $backupItem->policy_type,
policyId: $backupItem->policy_identifier,
policyPayload: $this->policyPayload,
includeAssignments: true
);

View File

@ -5,6 +5,9 @@
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Graph\NullGraphClient;
use App\Services\Intune\CompliancePolicyNormalizer;
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@ -27,6 +30,15 @@ public function register(): void
return $app->make(NullGraphClient::class);
});
$this->app->tag(
[
CompliancePolicyNormalizer::class,
DeviceConfigurationPolicyNormalizer::class,
SettingsCatalogPolicyNormalizer::class,
],
'policy-type-normalizers'
);
}
/**

View File

@ -24,6 +24,7 @@ public function __construct(
*
* @param BackupItem $backupItem The backup item to enrich
* @param Tenant $tenant Tenant model with credentials
* @param string $policyType Policy type key (e.g. deviceConfiguration)
* @param string $policyId Policy ID (external_id from Graph)
* @param array $policyPayload Full policy payload from Graph
* @param bool $includeAssignments Whether to fetch and include assignments
@ -32,6 +33,7 @@ public function __construct(
public function enrichWithAssignments(
BackupItem $backupItem,
Tenant $tenant,
string $policyType,
string $policyId,
array $policyPayload,
bool $includeAssignments = false
@ -58,7 +60,7 @@ public function enrichWithAssignments(
// Fetch assignments from Graph API
$graphOptions = $tenant->graphOptions();
$tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id;
$assignments = $this->assignmentFetcher->fetch($tenantId, $policyId, $graphOptions);
$assignments = $this->assignmentFetcher->fetch($policyType, $tenantId, $policyId, $graphOptions);
if (empty($assignments)) {
// No assignments or fetch failed
@ -91,12 +93,7 @@ public function enrichWithAssignments(
->contains(fn (array $group) => $group['orphaned'] ?? false);
}
$filterIds = collect($assignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filterIds = $this->extractAssignmentFilterIds($assignments);
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
@ -181,9 +178,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
}
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
if ($filterId && isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
if ($filterId) {
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
if ($filterType) {
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
}
if (isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
}
$assignment['target'] = $target;
@ -191,4 +200,28 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
return $assignment;
}, $assignments);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
private function extractAssignmentFilterIds(array $assignments): array
{
$filterIds = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
if (is_string($filterId) && $filterId !== '') {
$filterIds[] = $filterId;
}
}
return array_values(array_unique($filterIds));
}
}

View File

@ -4,6 +4,7 @@
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
@ -19,6 +20,7 @@ public function __construct(
private readonly GraphContractRegistry $contracts,
private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger,
private readonly AssignmentFilterResolver $assignmentFilterResolver,
) {}
/**
@ -56,6 +58,11 @@ public function restore(
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
$assignmentsPayloadKey = $contract['assignments_payload_key'] ?? 'assignments';
if (! is_string($assignmentsPayloadKey) || $assignmentsPayloadKey === '') {
$assignmentsPayloadKey = 'assignments';
}
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
@ -84,13 +91,39 @@ public function restore(
$assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? [];
if ($assignmentFilterMapping === []) {
$filterIds = $this->extractAssignmentFilterIds($assignments);
if ($filterIds !== []) {
$resolvedFilters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
foreach ($resolvedFilters as $filter) {
$filterId = $filter['id'] ?? null;
if (is_string($filterId) && $filterId !== '') {
$assignmentFilterMapping[$filterId] = $filterId;
}
}
}
}
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = $assignment['target'] ?? [];
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
$filterLocation = array_key_exists('deviceAndAppManagementAssignmentFilterId', $assignment) ? 'root' : 'target';
if (! is_string($filterId) && ! is_int($filterId)) {
$filterId = null;
}
if (is_string($filterId) && $filterId === '') {
$filterId = null;
}
if ($filterId !== null) {
if ($assignmentFilterMapping === []) {
@ -142,8 +175,12 @@ public function restore(
continue;
}
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
$assignment['target'] = $target;
if ($filterLocation === 'root') {
$assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
} else {
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
$assignment['target'] = $target;
}
}
$groupId = $assignment['target']['groupId'] ?? null;
@ -196,7 +233,7 @@ public function restore(
]);
$assignResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => ['assignments' => $preparedAssignments],
'json' => [$assignmentsPayloadKey => $preparedAssignments],
] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
@ -413,6 +450,34 @@ private function resolvePath(?string $template, string $policyId, ?string $assig
return $path;
}
/**
* @param array<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
private function extractAssignmentFilterIds(array $assignments): array
{
$filterIds = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
if (is_string($filterId) || is_int($filterId)) {
$filterId = (string) $filterId;
if ($filterId !== '') {
$filterIds[] = $filterId;
}
}
}
return array_values(array_unique($filterIds));
}
private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
{
if (! $mappedGroupId) {

View File

@ -8,89 +8,189 @@ class AssignmentFetcher
{
public function __construct(
private readonly MicrosoftGraphClient $graphClient,
private readonly GraphContractRegistry $contracts,
) {}
/**
* Fetch policy assignments with fallback strategy.
*
* Primary: GET /deviceManagement/configurationPolicies/{id}/assignments
* Fallback: GET /deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}'
* Primary: GET {assignments_list_path}
* Fallback: GET {resource}?$expand=assignments&$filter=id eq '{id}'
*
* @return array Returns assignment array or empty array on failure
*/
public function fetch(string $tenantId, string $policyId, array $options = []): array
{
public function fetch(
string $policyType,
string $tenantId,
string $policyId,
array $options = [],
bool $throwOnFailure = false
): array {
$contract = $this->contracts->get($policyType);
$listPathTemplate = $contract['assignments_list_path'] ?? null;
$resource = $contract['resource'] ?? null;
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
$context = [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
];
$primaryException = null;
$assignments = [];
// Try primary endpoint
try {
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
// Try primary endpoint
$assignments = $this->fetchPrimary($policyId, $requestOptions);
if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'count' => count($assignments),
]);
return $assignments;
}
// Try fallback with $expand
Log::debug('Primary endpoint returned empty, trying fallback', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
]);
$assignments = $this->fetchWithExpand($policyId, $requestOptions);
if (! empty($assignments)) {
Log::debug('Fetched assignments via fallback endpoint', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
'count' => count($assignments),
]);
return $assignments;
}
// Both methods returned empty
Log::debug('No assignments found for policy', [
'tenant_id' => $tenantId,
'policy_id' => $policyId,
]);
return [];
$assignments = $this->fetchPrimary(
$listPathTemplate,
$policyId,
$requestOptions,
$context,
$throwOnFailure
);
} catch (GraphException $e) {
Log::warning('Failed to fetch assignments', [
$primaryException = $e;
}
if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
'error' => $e->getMessage(),
'context' => $e->context,
'count' => count($assignments),
]);
return $assignments;
}
// Try fallback with $expand
Log::debug('Primary endpoint returned empty, trying fallback', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
]);
if (! is_string($resource) || $resource === '') {
Log::debug('Assignments resource not configured for policy type', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
]);
if ($throwOnFailure && $primaryException) {
Log::warning('Failed to fetch assignments', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
'error' => $primaryException->getMessage(),
'context' => $primaryException->context,
]);
throw $primaryException;
}
return [];
}
$fallbackException = null;
try {
$assignments = $this->fetchWithExpand(
$resource,
$policyId,
$requestOptions,
$context,
$throwOnFailure
);
} catch (GraphException $e) {
$fallbackException = $e;
}
if (! empty($assignments)) {
Log::debug('Fetched assignments via fallback endpoint', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
'count' => count($assignments),
]);
return $assignments;
}
// Both methods returned empty
Log::debug('No assignments found for policy', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
]);
if ($throwOnFailure && ($fallbackException || $primaryException)) {
$exception = $fallbackException ?? $primaryException;
Log::warning('Failed to fetch assignments', [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
'error' => $exception->getMessage(),
'context' => $exception->context,
]);
throw $exception;
}
return [];
}
/**
* Fetch assignments using primary endpoint.
*/
private function fetchPrimary(string $policyId, array $options): array
{
$path = "/deviceManagement/configurationPolicies/{$policyId}/assignments";
private function fetchPrimary(
?string $listPathTemplate,
string $policyId,
array $options,
array $context,
bool $throwOnFailure
): array {
if (! is_string($listPathTemplate) || $listPathTemplate === '') {
return [];
}
$path = $this->resolvePath($listPathTemplate, $policyId);
if ($path === null) {
return [];
}
$response = $this->graphClient->request('GET', $path, $options);
if ($response->failed()) {
$this->logAssignmentFailure('primary', $response, $context + ['path' => $path]);
if ($throwOnFailure) {
throw new GraphException(
$this->resolveErrorMessage($response),
$response->status,
$context + ['path' => $path]
);
}
return [];
}
return $response->data['value'] ?? [];
}
/**
* Fetch assignments using $expand fallback.
*/
private function fetchWithExpand(string $policyId, array $options): array
{
$path = '/deviceManagement/configurationPolicies';
private function fetchWithExpand(
string $resource,
string $policyId,
array $options,
array $context,
bool $throwOnFailure
): array {
$path = $resource;
$params = [
'$expand' => 'assignments',
'$filter' => "id eq '{$policyId}'",
@ -100,6 +200,20 @@ private function fetchWithExpand(string $policyId, array $options): array
'query' => $params,
]));
if ($response->failed()) {
$this->logAssignmentFailure('fallback', $response, $context + ['path' => $path]);
if ($throwOnFailure) {
throw new GraphException(
$this->resolveErrorMessage($response),
$response->status,
$context + ['path' => $path]
);
}
return [];
}
$policies = $response->data['value'] ?? [];
if (empty($policies)) {
@ -108,4 +222,41 @@ private function fetchWithExpand(string $policyId, array $options): array
return $policies[0]['assignments'] ?? [];
}
private function resolvePath(string $template, string $policyId): ?string
{
if ($template === '') {
return null;
}
return str_replace('{id}', urlencode($policyId), $template);
}
private function resolveErrorMessage(GraphResponse $response): string
{
$error = $response->errors[0] ?? null;
if (is_array($error)) {
if (isset($error['message']) && is_string($error['message'])) {
return $error['message'];
}
return json_encode($error, JSON_UNESCAPED_SLASHES) ?: 'Graph request failed';
}
if (is_string($error) && $error !== '') {
return $error;
}
return 'Graph request failed';
}
private function logAssignmentFailure(string $stage, GraphResponse $response, array $context): void
{
Log::warning('Assignment fetch failed', $context + [
'stage' => $stage,
'status' => $response->status,
'errors' => $response->errors,
]);
}
}

View File

@ -69,8 +69,7 @@ public function sanitizeUpdatePayload(string $policyType, array $snapshot): arra
$whitelist = $contract['update_whitelist'] ?? null;
$stripKeys = array_merge($this->readOnlyKeys(), $contract['update_strip_keys'] ?? []);
$mapping = $contract['update_map'] ?? [];
$stripOdata = $whitelist !== null || ! empty($contract['update_strip_keys']);
$stripOdata = $contract['strip_odata'] ?? ($whitelist !== null || ! empty($contract['update_strip_keys']));
$result = $this->sanitizeArray($snapshot, $whitelist, $stripKeys, $stripOdata, $mapping);

View File

@ -42,6 +42,7 @@ public function createBackupSet(
$policies = Policy::query()
->where('tenant_id', $tenant->id)
->whereIn('id', $policyIds)
->whereNull('ignored_at')
->get();
$backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) {
@ -182,6 +183,7 @@ public function addPoliciesToSet(
$policies = Policy::query()
->where('tenant_id', $tenant->id)
->whereIn('id', $policyIds)
->whereNull('ignored_at')
->get();
$metadata = $backupSet->metadata ?? [];
@ -303,6 +305,12 @@ private function snapshotPolicy(
$metadata['warnings'] = array_values(array_unique($metadataWarnings));
}
$capturedScopeTags = $captured['scope_tags'] ?? null;
if (is_array($capturedScopeTags)) {
$metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null;
$metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null;
}
// Create BackupItem as a copy/reference of the PolicyVersion
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,

View File

@ -0,0 +1,296 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class CompliancePolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
/**
* @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->buildComplianceBlocks($snapshot) as $block) {
$normalized['settings'][] = $block;
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildComplianceBlocks(array $snapshot): array
{
$blocks = [];
$groups = $this->groupedFields();
$usedKeys = [];
foreach ($groups as $title => $group) {
$rows = $this->buildRows($snapshot, $group['keys'], $group['labels'] ?? []);
if ($rows === []) {
continue;
}
$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 [
'Password & Access' => [
'keys' => [
'passwordRequired',
'passwordRequiredType',
'passwordBlockSimple',
'passwordMinimumLength',
'passwordMinimumCharacterSetCount',
'passwordExpirationDays',
'passwordMinutesOfInactivityBeforeLock',
'passwordPreviousPasswordBlockCount',
'passwordRequiredToUnlockFromIdle',
],
'labels' => [
'passwordRequired' => 'Password required',
'passwordRequiredType' => 'Password required type',
'passwordBlockSimple' => 'Block simple passwords',
'passwordMinimumLength' => 'Password minimum length',
'passwordMinimumCharacterSetCount' => 'Password minimum character set count',
'passwordExpirationDays' => 'Password expiration days',
'passwordMinutesOfInactivityBeforeLock' => 'Password idle lock (minutes)',
'passwordPreviousPasswordBlockCount' => 'Password history count',
'passwordRequiredToUnlockFromIdle' => 'Password required to unlock from idle',
],
],
'Defender & Threat Protection' => [
'keys' => [
'defenderEnabled',
'defenderVersion',
'antivirusRequired',
'antiSpywareRequired',
'rtpEnabled',
'signatureOutOfDate',
'deviceThreatProtectionEnabled',
'deviceThreatProtectionRequiredSecurityLevel',
'requireHealthyDeviceReport',
],
'labels' => [
'defenderEnabled' => 'Microsoft Defender enabled',
'defenderVersion' => 'Defender version',
'antivirusRequired' => 'Antivirus required',
'antiSpywareRequired' => 'Anti-spyware required',
'rtpEnabled' => 'Real-time protection enabled',
'signatureOutOfDate' => 'Signature out of date (days)',
'deviceThreatProtectionEnabled' => 'Device threat protection enabled',
'deviceThreatProtectionRequiredSecurityLevel' => 'Threat protection required level',
'requireHealthyDeviceReport' => 'Require healthy device report',
],
],
'Encryption & Integrity' => [
'keys' => [
'bitLockerEnabled',
'storageRequireEncryption',
'tpmRequired',
'secureBootEnabled',
'codeIntegrityEnabled',
'memoryIntegrityEnabled',
'kernelDmaProtectionEnabled',
'firmwareProtectionEnabled',
'virtualizationBasedSecurityEnabled',
'earlyLaunchAntiMalwareDriverEnabled',
],
'labels' => [
'bitLockerEnabled' => 'BitLocker required',
'storageRequireEncryption' => 'Storage encryption required',
'tpmRequired' => 'TPM required',
'secureBootEnabled' => 'Secure boot required',
'codeIntegrityEnabled' => 'Code integrity required',
'memoryIntegrityEnabled' => 'Memory integrity required',
'kernelDmaProtectionEnabled' => 'Kernel DMA protection required',
'firmwareProtectionEnabled' => 'Firmware protection required',
'virtualizationBasedSecurityEnabled' => 'Virtualization-based security required',
'earlyLaunchAntiMalwareDriverEnabled' => 'Early launch anti-malware required',
],
],
'Operating System' => [
'keys' => [
'osMinimumVersion',
'osMaximumVersion',
'mobileOsMinimumVersion',
'mobileOsMaximumVersion',
'validOperatingSystemBuildRanges',
'wslDistributions',
],
'labels' => [
'osMinimumVersion' => 'OS minimum version',
'osMaximumVersion' => 'OS maximum version',
'mobileOsMinimumVersion' => 'Mobile OS minimum version',
'mobileOsMaximumVersion' => 'Mobile OS maximum version',
'validOperatingSystemBuildRanges' => 'Valid OS build ranges',
'wslDistributions' => 'Allowed WSL distributions',
],
],
'Firewall' => [
'keys' => [
'activeFirewallRequired',
],
'labels' => [
'activeFirewallRequired' => 'Active firewall required',
],
],
'Compliance Signals' => [
'keys' => [
'configurationManagerComplianceRequired',
'deviceCompliancePolicyScript',
],
'labels' => [
'configurationManagerComplianceRequired' => 'ConfigMgr compliance required',
'deviceCompliancePolicyScript' => 'Compliance policy script',
],
],
];
}
/**
* @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[] = [
'label' => $labels[$key] ?? Str::headline($key),
'value' => $this->formatValue($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[] = [
'label' => Str::headline($key),
'value' => $this->formatValue($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',
'displayName',
'description',
'name',
'platform',
'platforms',
'technologies',
'settingCount',
'settingsCount',
'templateReference',
];
}
private function formatValue(mixed $value): mixed
{
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT);
}
return $value;
}
}

View File

@ -0,0 +1,918 @@
<?php
namespace App\Services\Intune;
use App\Models\Policy;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class DefaultPolicyNormalizer implements PolicyTypeNormalizer
{
private const SETTINGS_CATALOG_MAX_ROWS = 1000;
private const SETTINGS_CATALOG_MAX_DEPTH = 8;
/**
* Normalize raw Intune snapshots into display-friendly blocks and warnings.
*/
public function __construct(
private readonly SnapshotValidator $validator,
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
private readonly SettingsCatalogCategoryResolver $categoryResolver,
) {}
public function supports(string $policyType): bool
{
return true;
}
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>, context?: string, record_id?: string}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$resultWarnings = [];
$status = 'success';
$settingsTable = null;
$validation = $this->validator->validate($snapshot);
$resultWarnings = array_merge($resultWarnings, $validation['warnings']);
$odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform);
if ($odataWarning) {
$resultWarnings[] = $odataWarning;
}
if ($snapshot === []) {
return [
'status' => 'warning',
'settings' => [],
'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))),
];
}
$settings = [];
if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) {
$settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']);
}
if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
if ($policyType === 'settingsCatalogPolicy') {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']);
$settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
}
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
if ($policyType === 'settingsCatalogPolicy') {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta');
$settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
}
} elseif ($policyType === 'settingsCatalogPolicy') {
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.';
}
$settings[] = $this->normalizeStandard($snapshot);
if (! empty($resultWarnings)) {
$status = 'warning';
}
$result = [
'status' => $status,
'settings' => array_values(array_filter($settings)),
'warnings' => array_values(array_unique($resultWarnings)),
];
if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) {
$result['settings_table'] = $settingsTable;
}
return $result;
}
/**
* Flatten normalized settings into key/value pairs for diffing.
*
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
$map = [];
if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) {
foreach ($normalized['settings_table']['rows'] as $row) {
if (! is_array($row)) {
continue;
}
$key = $row['path'] ?? $row['definition'] ?? 'entry';
$map[$key] = $row['value'] ?? null;
}
}
foreach ($normalized['settings'] as $block) {
if (($block['type'] ?? null) === 'table') {
foreach ($block['rows'] ?? [] as $row) {
$key = $row['path'] ?? $row['label'] ?? 'entry';
$map[$key] = $row['value'] ?? null;
}
continue;
}
foreach ($block['entries'] ?? [] as $entry) {
$key = $entry['key'] ?? 'entry';
$map[$key] = $entry['value'] ?? null;
}
}
return $map;
}
/**
* @param array<int, array<string, mixed>> $omaSettings
*/
private function normalizeOmaSettings(array $omaSettings): array
{
$rows = [];
foreach ($omaSettings as $setting) {
if (! is_array($setting)) {
continue;
}
$rows[] = [
'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a',
'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null,
'label' => $setting['displayName'] ?? null,
'description' => $setting['description'] ?? null,
];
}
return [
'type' => 'table',
'title' => 'OMA-URI settings',
'rows' => $rows,
];
}
/**
* @param array<int, array<string, mixed>> $settings
*/
private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array
{
$entries = [];
foreach ($settings as $setting) {
if (! is_array($setting)) {
continue;
}
$key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting';
$value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null;
if ($value === null && isset($setting['value']['value'])) {
$value = $setting['value']['value'];
}
if (is_array($value)) {
$value = json_encode($value, JSON_PRETTY_PRINT);
}
$entries[] = [
'key' => $key,
'value' => $value,
];
}
return [
'type' => 'keyValue',
'title' => $title,
'entries' => $entries,
];
}
/**
* @param array<int, mixed> $settings
* @return array{table: array<string, mixed>, warnings: array<int, string>}
*/
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array
{
$flattened = $this->flattenSettingsCatalogSettingInstances($settings);
return [
'table' => [
'title' => $title,
'rows' => $flattened['rows'],
],
'warnings' => $flattened['warnings'],
];
}
/**
* @param array<int, mixed> $settings
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
*/
private function flattenSettingsCatalogSettingInstances(array $settings): array
{
$rows = [];
$warnings = [];
$rowCount = 0;
$warnedDepthLimit = false;
$warnedRowLimit = false;
// Extract all definition IDs first to resolve display names in batch
$definitionIds = $this->extractAllDefinitionIds($settings);
$definitions = $this->definitionResolver->resolve($definitionIds);
// Extract all category IDs and resolve them
$categoryIds = array_filter(array_unique(array_map(
fn ($def) => $def['categoryId'] ?? null,
$definitions
)));
$categories = $this->categoryResolver->resolve($categoryIds);
$categoryNames = [];
foreach ($categoryIds as $categoryId) {
$categoryName = $categories[$categoryId]['displayName'] ?? null;
if (is_string($categoryName) && $categoryName !== '') {
$categoryNames[] = $categoryName;
}
}
$categoryNames = array_values(array_unique($categoryNames));
$defaultCategoryName = count($categoryNames) === 1 ? $categoryNames[0] : null;
$walk = function (array $nodes, array $pathParts, int $depth) use (
&$walk,
&$rows,
&$warnings,
&$rowCount,
&$warnedDepthLimit,
&$warnedRowLimit,
$definitions,
$categories,
$defaultCategoryName
): void {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
if (! $warnedRowLimit) {
$warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS);
$warnedRowLimit = true;
}
return;
}
if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) {
if (! $warnedDepthLimit) {
$warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH);
$warnedDepthLimit = true;
}
return;
}
foreach ($nodes as $node) {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
break;
}
if (! is_array($node)) {
continue;
}
$instance = $this->extractSettingsCatalogSettingInstance($node);
$definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance);
$instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null);
$rawInstanceType = is_string($instanceType) ? ltrim($instanceType, '#') : null;
$currentPathParts = array_merge($pathParts, [$definitionId]);
$path = implode(' > ', $currentPathParts);
$value = $this->extractSettingsCatalogValue($node, $instance);
// Get metadata from resolved definitions
$definition = $definitions[$definitionId] ?? null;
$displayName = $definition['displayName'] ??
$this->definitionResolver->prettifyDefinitionId($definitionId);
$categoryId = $definition['categoryId'] ?? null;
$categoryName = $categoryId ? ($categories[$categoryId]['displayName'] ?? '-') : '-';
$description = $definition['description'] ?? null;
if (! $categoryId && ! empty($pathParts)) {
foreach (array_reverse($pathParts) as $ancestorDefinitionId) {
if (! is_string($ancestorDefinitionId) || $ancestorDefinitionId === '') {
continue;
}
$ancestorDefinition = $definitions[$ancestorDefinitionId] ?? null;
$ancestorCategoryId = $ancestorDefinition['categoryId'] ?? null;
if ($ancestorCategoryId) {
$categoryId = $ancestorCategoryId;
$categoryName = $categories[$categoryId]['displayName'] ?? '-';
break;
}
}
}
if (
! $categoryId
&& $defaultCategoryName
&& (str_contains($definitionId, '{') || str_contains($definitionId, '}'))
) {
$categoryName = $defaultCategoryName;
}
// Convert technical type to user-friendly data type
$dataType = $this->getUserFriendlyDataType($rawInstanceType, $value);
$rows[] = [
'definition' => $displayName,
'definition_id' => $definitionId,
'category' => $categoryName,
'data_type' => $dataType,
'value' => $this->stringifySettingsCatalogValue($value),
'description' => $description ? Str::limit($description, 100) : '-',
'path' => $path,
'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node),
];
$rowCount++;
if (! is_array($instance)) {
continue;
}
$nested = $this->extractSettingsCatalogChildren($instance);
if (! empty($nested)) {
$walk($nested, $currentPathParts, $depth + 1);
}
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) {
$collections = $instance['groupSettingCollectionValue'] ?? [];
if (! is_array($collections)) {
continue;
}
foreach (array_values($collections) as $index => $collection) {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
break;
}
if (! is_array($collection)) {
continue;
}
$children = $collection['children'] ?? [];
if (! is_array($children) || empty($children)) {
continue;
}
$walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1);
}
}
}
};
$walk($settings, [], 1);
return [
'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS),
'warnings' => $warnings,
];
}
private function extractSettingsCatalogSettingInstance(array $setting): ?array
{
$instance = $setting['settingInstance'] ?? null;
if (is_array($instance)) {
return $instance;
}
if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) {
return $setting;
}
return null;
}
private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string
{
$candidates = [
$setting['definitionId'] ?? null,
$setting['settingDefinitionId'] ?? null,
$setting['name'] ?? null,
$setting['displayName'] ?? null,
$instance['settingDefinitionId'] ?? null,
$instance['definitionId'] ?? null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}
return 'setting';
}
private function formatSettingsCatalogInstanceType(?string $type): ?string
{
if (! $type) {
return null;
}
$type = Str::afterLast($type, '.');
foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) {
if (Str::startsWith($type, $prefix)) {
$type = substr($type, strlen($prefix));
break;
}
}
return $type !== '' ? $type : null;
}
private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool
{
$type = $instance['@odata.type'] ?? null;
if (! is_string($type)) {
return false;
}
return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true);
}
/**
* @return array<int, mixed>
*/
private function extractSettingsCatalogChildren(array $instance): array
{
foreach (['children', 'choiceSettingValue.children', 'groupSettingValue.children'] as $path) {
$children = Arr::get($instance, $path);
if (is_array($children) && ! empty($children)) {
return $children;
}
}
return [];
}
private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed
{
if ($instance === null) {
return $setting['value'] ?? null;
}
$type = $instance['@odata.type'] ?? null;
$type = is_string($type) ? $type : '';
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
$simple = $instance['simpleSettingValue'] ?? null;
if (is_array($simple)) {
return $simple['value'] ?? $simple;
}
return $simple;
}
if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) {
$choice = $instance['choiceSettingValue'] ?? null;
if (is_array($choice)) {
return $choice['value'] ?? $choice;
}
return $choice;
}
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) {
return '(group)';
}
$fallback = $instance;
unset($fallback['children']);
return $fallback;
}
private function stringifySettingsCatalogValue(mixed $value): string
{
if ($value === null) {
return '-';
}
return $this->formatSettingsCatalogValue($value);
}
private function pruneSettingsCatalogRaw(mixed $raw): mixed
{
if (! is_array($raw)) {
return $raw;
}
$pruned = $raw;
unset($pruned['children'], $pruned['groupSettingCollectionValue']);
return $pruned;
}
private function normalizeStandard(array $snapshot): array
{
$metadataKeys = [
'@odata.context',
'@odata.type',
'id',
'version',
'createdDateTime',
'lastModifiedDateTime',
'supportsScopeTags',
'roleScopeTagIds',
'assignments',
'createdBy',
'lastModifiedBy',
'omaSettings',
'settings',
'settingsDelta',
];
$filtered = Arr::except($snapshot, $metadataKeys);
$entries = [];
foreach ($filtered as $key => $value) {
if (is_array($value)) {
$value = json_encode($value, JSON_PRETTY_PRINT);
}
$entries[] = [
'key' => Str::headline((string) $key),
'value' => $value,
];
}
return [
'type' => 'keyValue',
'title' => 'General',
'entries' => $entries,
];
}
/**
* Normalize Settings Catalog policy with grouped, readable settings (T011-T014).
*
* @param array<int, mixed> $settings
* @return array{type: string, groups: array<int, array<string, mixed>>}
*/
public function normalizeSettingsCatalogGrouped(array $settings): array
{
// Extract all definition IDs
$definitionIds = $this->extractAllDefinitionIds($settings);
// Resolve definitions
$definitions = $this->definitionResolver->resolve($definitionIds);
// Flatten settings
$flattened = $this->flattenSettingsCatalogForGrouping($settings);
// Group by category
$groups = $this->groupSettingsByCategory($flattened, $definitions);
return [
'type' => 'settings_catalog_grouped',
'groups' => $groups,
];
}
/**
* Extract all definition IDs from settings array recursively.
*/
private function extractAllDefinitionIds(array $settings): array
{
$ids = [];
foreach ($settings as $setting) {
// Top-level settings have settingInstance wrapper
if (isset($setting['settingInstance']['settingDefinitionId'])) {
$ids[] = $setting['settingInstance']['settingDefinitionId'];
$instance = $setting['settingInstance'];
}
// Nested children have settingDefinitionId directly (they ARE the instance)
elseif (isset($setting['settingDefinitionId'])) {
$ids[] = $setting['settingDefinitionId'];
$instance = $setting;
} else {
continue;
}
// Handle nested children using the comprehensive children extraction method
$children = $this->extractSettingsCatalogChildren($instance);
if (! empty($children)) {
$childIds = $this->extractAllDefinitionIds($children);
$ids = array_merge($ids, $childIds);
}
// Also handle nested children in group collections (fallback for legacy code)
if (isset($instance['groupSettingCollectionValue'])) {
foreach ($instance['groupSettingCollectionValue'] as $group) {
if (isset($group['children']) && is_array($group['children'])) {
$childIds = $this->extractAllDefinitionIds($group['children']);
$ids = array_merge($ids, $childIds);
}
}
}
}
return array_unique($ids);
}
/**
* Flatten settings for grouping with value formatting.
*/
private function flattenSettingsCatalogForGrouping(array $settings): array
{
$rows = [];
$walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void {
foreach ($nodes as $node) {
if (! is_array($node)) {
continue;
}
$instance = $this->extractSettingsCatalogSettingInstance($node);
$definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance);
$value = $this->extractSettingsCatalogValue($node, $instance);
$isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance);
// Only add to rows if NOT a group collection (those are containers)
if (! $isGroupCollection) {
$rows[] = [
'definition_id' => $definitionId,
'value_raw' => $value,
'value_display' => $this->formatSettingsCatalogValue($value),
'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null,
];
}
// Handle nested children
if (is_array($instance)) {
$nested = $this->extractSettingsCatalogChildren($instance);
if (! empty($nested)) {
$walk($nested, array_merge($pathParts, [$definitionId]));
}
// Handle group collections
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) {
$collections = $instance['groupSettingCollectionValue'] ?? [];
if (is_array($collections)) {
foreach ($collections as $collection) {
if (isset($collection['children']) && is_array($collection['children'])) {
$walk($collection['children'], array_merge($pathParts, [$definitionId]));
}
}
}
}
}
}
};
$walk($settings, []);
return $rows;
}
/**
* Format setting value for display (T012).
*/
private function formatSettingsCatalogValue(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_int($value)) {
return number_format($value);
}
if (is_string($value)) {
// Remove {tenantid} placeholder
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
$value = preg_replace('/_+/', '_', $value);
// Extract choice label from choice values (last meaningful part)
// Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0"
if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) {
$parts = explode('_', $value);
$lastPart = end($parts);
// Check for boolean-like values
if (in_array(strtolower($lastPart), ['true', 'false'])) {
return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled';
}
// If last part is just a number, take second-to-last too
if (is_numeric($lastPart) && count($parts) > 1) {
$secondLast = $parts[count($parts) - 2];
// Map common values
$mapping = [
'lowercaseletters' => 'Lowercase Letters',
'uppercaseletters' => 'Uppercase Letters',
'specialcharacters' => 'Special Characters',
'digits' => 'Digits',
];
if (isset($mapping[strtolower($secondLast)])) {
return $mapping[strtolower($secondLast)].': '.$lastPart;
}
if (in_array((string) $lastPart, ['0', '1'], true)) {
return (string) $lastPart === '1' ? 'Enabled' : 'Disabled';
}
return Str::title($secondLast).': '.$lastPart;
}
return Str::title($lastPart);
}
// Truncate long strings
return Str::limit($value, 100);
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
/**
* Group settings by category (T013).
*/
private function groupSettingsByCategory(array $rows, array $definitions): array
{
$grouped = [];
foreach ($rows as $row) {
$definitionId = $row['definition_id'];
$definition = $definitions[$definitionId] ?? null;
// Determine category
$categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId);
$categoryTitle = $this->formatCategoryTitle($categoryId);
if (! isset($grouped[$categoryId])) {
$grouped[$categoryId] = [
'title' => $categoryTitle,
'description' => null,
'settings' => [],
];
}
$grouped[$categoryId]['settings'][] = [
'label' => $definition['displayName'] ?? $row['definition_id'],
'value' => $row['value_display'], // Primary value for display
'value_display' => $row['value_display'],
'value_raw' => $row['value_raw'],
'help_text' => $definition['helpText'] ?? $definition['description'] ?? null,
'definition_id' => $definitionId,
'instance_type' => $row['instance_type'],
'is_fallback' => $definition['isFallback'] ?? false,
];
}
// Sort groups by title
uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title']));
// Sort settings within each group by label for stable ordering
foreach ($grouped as $cid => $g) {
if (isset($g['settings']) && is_array($g['settings'])) {
usort($g['settings'], function ($a, $b) {
return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? ''));
});
$grouped[$cid]['settings'] = $g['settings'];
}
}
return array_values($grouped);
}
/**
* Extract category from definition ID (fallback grouping).
*/
private function extractCategoryFromDefinitionId(string $definitionId): string
{
$parts = explode('_', $definitionId);
// Use first 2-3 segments as category
return implode('_', array_slice($parts, 0, min(3, count($parts))));
}
/**
* Format category ID into readable title.
*/
private function formatCategoryTitle(string $categoryId): string
{
// Try to prettify known patterns
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) {
// It's a UUID - likely a category ID from Graph
return 'Additional Settings';
}
// Clean up common prefixes
$title = str_replace('device_vendor_msft_', '', $categoryId);
$title = Str::title(str_replace('_', ' ', $title));
// Known mappings
$mappings = [
'Passportforwork' => 'Windows Hello for Business',
];
foreach ($mappings as $search => $replace) {
$title = str_replace($search, $replace, $title);
}
return $title;
}
/**
* Convert technical instance type to user-friendly data type.
*/
private function getUserFriendlyDataType(?string $instanceType, mixed $value): string
{
if (! $instanceType) {
return $this->guessDataTypeFromValue($value);
}
$type = strtolower($instanceType);
if (str_contains($type, 'choice')) {
return 'Choice';
}
if (str_contains($type, 'simplesetting')) {
return $this->guessDataTypeFromValue($value);
}
if (str_contains($type, 'groupsetting')) {
return 'Group';
}
return 'Text';
}
/**
* Guess data type from value.
*/
private function guessDataTypeFromValue(mixed $value): string
{
if (is_bool($value)) {
return 'Boolean';
}
if (is_int($value)) {
return 'Number';
}
if (is_string($value)) {
// Check if it's a boolean-like string
if (in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'])) {
return 'Boolean';
}
// Check if numeric string
if (is_numeric($value)) {
return 'Number';
}
return 'Text';
}
if (is_array($value)) {
return 'List';
}
return 'Text';
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace App\Services\Intune;
use Illuminate\Support\Str;
class DeviceConfigurationPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'deviceConfiguration';
}
/**
* @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'
));
$configurationBlock = $this->buildConfigurationBlock($snapshot);
if ($configurationBlock !== null) {
$normalized['settings'][] = $configurationBlock;
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
}
/**
* @return array{type: string, title: string, entries: array<int, array<string, mixed>>}|null
*/
private function buildConfigurationBlock(array $snapshot): ?array
{
$entries = [];
$ignoredKeys = $this->ignoredKeys();
foreach ($snapshot as $key => $value) {
if (! is_string($key)) {
continue;
}
if (in_array($key, $ignoredKeys, true)) {
continue;
}
$entries[] = [
'key' => Str::headline($key),
'value' => $this->formatValue($value),
];
}
if ($entries === []) {
return null;
}
return [
'type' => 'keyValue',
'title' => 'Configuration',
'entries' => $entries,
];
}
/**
* @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',
'displayName',
'description',
'name',
'platform',
'platforms',
'technologies',
'settingCount',
'settingsCount',
'templateReference',
];
}
private function formatValue(mixed $value): mixed
{
if (is_array($value)) {
return json_encode($value, JSON_PRETTY_PRINT);
}
return $value;
}
}

View File

@ -58,7 +58,15 @@ public function capture(
// 2. Fetch assignments if requested
if ($includeAssignments) {
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions);
$rawAssignments = $this->assignmentFetcher->fetch(
$policy->policy_type,
$tenantIdentifier,
$policy->external_id,
$graphOptions,
true
);
$captureMetadata['assignments_fetched'] = true;
$captureMetadata['assignments_count'] = count($rawAssignments);
if (! empty($rawAssignments)) {
$resolvedGroups = [];
@ -77,12 +85,7 @@ public function capture(
->contains(fn (array $group) => $group['orphaned'] ?? false);
}
$filterIds = collect($rawAssignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filterIds = $this->extractAssignmentFilterIds($rawAssignments);
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
@ -90,7 +93,6 @@ public function capture(
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
$captureMetadata['assignments_count'] = count($rawAssignments);
}
} catch (\Throwable $e) {
$captureMetadata['assignments_fetch_failed'] = true;
@ -242,7 +244,15 @@ public function ensureVersionHasAssignments(
if ($includeAssignments && $version->assignments === null) {
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions);
$rawAssignments = $this->assignmentFetcher->fetch(
$policy->policy_type,
$tenantIdentifier,
$policy->external_id,
$graphOptions,
true
);
$metadata['assignments_fetched'] = true;
$metadata['assignments_count'] = count($rawAssignments);
if (! empty($rawAssignments)) {
$resolvedGroups = [];
@ -261,12 +271,7 @@ public function ensureVersionHasAssignments(
->contains(fn (array $group) => $group['orphaned'] ?? false);
}
$filterIds = collect($rawAssignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filterIds = $this->extractAssignmentFilterIds($rawAssignments);
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
@ -274,7 +279,6 @@ public function ensureVersionHasAssignments(
->all();
$assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames);
$metadata['assignments_count'] = count($rawAssignments);
}
} catch (\Throwable $e) {
$metadata['assignments_fetch_failed'] = true;
@ -336,9 +340,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
}
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
if ($filterId && isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
if ($filterId) {
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
if ($filterType) {
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
}
if (isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
}
$assignment['target'] = $target;
@ -347,6 +363,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
}, $assignments);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
private function extractAssignmentFilterIds(array $assignments): array
{
$filterIds = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
if (is_string($filterId) && $filterId !== '') {
$filterIds[] = $filterId;
}
}
return array_values(array_unique($filterIds));
}
/**
* @param array<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -2,912 +2,65 @@
namespace App\Services\Intune;
use App\Models\Policy;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Container\Attributes\Tag;
class PolicyNormalizer
{
private const SETTINGS_CATALOG_MAX_ROWS = 1000;
private const SETTINGS_CATALOG_MAX_DEPTH = 8;
/**
* Normalize raw Intune snapshots into display-friendly blocks and warnings.
* @var array<int, PolicyTypeNormalizer>
*/
private array $typeNormalizers;
public function __construct(
private readonly SnapshotValidator $validator,
private readonly SettingsCatalogDefinitionResolver $definitionResolver,
private readonly SettingsCatalogCategoryResolver $categoryResolver,
) {}
private readonly DefaultPolicyNormalizer $defaultNormalizer,
#[Tag('policy-type-normalizers')]
iterable $typeNormalizers = [],
) {
$normalizers = is_array($typeNormalizers)
? $typeNormalizers
: iterator_to_array($typeNormalizers);
/**
* @return array{status: string, settings: array<int, array<string, mixed>>, settings_table?: array<string, mixed>, warnings: array<int, string>, context?: string, record_id?: string}
*/
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
$snapshot = $snapshot ?? [];
$resultWarnings = [];
$status = 'success';
$settingsTable = null;
$validation = $this->validator->validate($snapshot);
$resultWarnings = array_merge($resultWarnings, $validation['warnings']);
$odataWarning = Policy::odataTypeWarning($snapshot, $policyType, $platform);
if ($odataWarning) {
$resultWarnings[] = $odataWarning;
}
if ($snapshot === []) {
return [
'status' => 'warning',
'settings' => [],
'warnings' => array_values(array_unique(array_merge($resultWarnings, ['No snapshot available']))),
];
}
$settings = [];
if (isset($snapshot['omaSettings']) && is_array($snapshot['omaSettings'])) {
$settings[] = $this->normalizeOmaSettings($snapshot['omaSettings']);
}
if (isset($snapshot['settings']) && is_array($snapshot['settings'])) {
if ($policyType === 'settingsCatalogPolicy') {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']);
$settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settings']);
}
} elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) {
if ($policyType === 'settingsCatalogPolicy') {
$normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta');
$settingsTable = $normalized['table'];
$resultWarnings = array_merge($resultWarnings, $normalized['warnings']);
} else {
$settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta');
}
} elseif ($policyType === 'settingsCatalogPolicy') {
$resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.';
}
$settings[] = $this->normalizeStandard($snapshot);
if (! empty($resultWarnings)) {
$status = 'warning';
}
$result = [
'status' => $status,
'settings' => array_values(array_filter($settings)),
'warnings' => array_values(array_unique($resultWarnings)),
];
if (is_array($settingsTable) && ! empty($settingsTable['rows'] ?? [])) {
$result['settings_table'] = $settingsTable;
}
return $result;
$this->typeNormalizers = array_values(array_filter(
$normalizers,
fn (mixed $normalizer) => $normalizer instanceof PolicyTypeNormalizer
));
}
/**
* @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
{
return $this->resolveNormalizer($policyType)
->normalize($snapshot, $policyType, $platform);
}
/**
* Flatten normalized settings into key/value pairs for diffing.
*
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
$normalized = $this->normalize($snapshot ?? [], $policyType, $platform);
$map = [];
if (isset($normalized['settings_table']['rows']) && is_array($normalized['settings_table']['rows'])) {
foreach ($normalized['settings_table']['rows'] as $row) {
if (! is_array($row)) {
continue;
}
$key = $row['path'] ?? $row['definition'] ?? 'entry';
$map[$key] = $row['value'] ?? null;
}
}
foreach ($normalized['settings'] as $block) {
if (($block['type'] ?? null) === 'table') {
foreach ($block['rows'] ?? [] as $row) {
$key = $row['path'] ?? $row['label'] ?? 'entry';
$map[$key] = $row['value'] ?? null;
}
continue;
}
foreach ($block['entries'] ?? [] as $entry) {
$key = $entry['key'] ?? 'entry';
$map[$key] = $entry['value'] ?? null;
}
}
return $map;
return $this->resolveNormalizer($policyType)
->flattenForDiff($snapshot, $policyType, $platform);
}
/**
* @param array<int, array<string, mixed>> $omaSettings
*/
private function normalizeOmaSettings(array $omaSettings): array
{
$rows = [];
foreach ($omaSettings as $setting) {
if (! is_array($setting)) {
continue;
}
$rows[] = [
'path' => $setting['omaUri'] ?? $setting['path'] ?? 'n/a',
'value' => $setting['value'] ?? $setting['displayValue'] ?? $setting['secretReferenceValueId'] ?? null,
'label' => $setting['displayName'] ?? null,
'description' => $setting['description'] ?? null,
];
}
return [
'type' => 'table',
'title' => 'OMA-URI settings',
'rows' => $rows,
];
}
/**
* @param array<int, array<string, mixed>> $settings
*/
private function normalizeSettingsCatalog(array $settings, string $title = 'Settings'): array
{
$entries = [];
foreach ($settings as $setting) {
if (! is_array($setting)) {
continue;
}
$key = $setting['displayName'] ?? $setting['name'] ?? $setting['definitionId'] ?? 'setting';
$value = $setting['value'] ?? $setting['settingInstance']['simpleSettingValue'] ?? $setting['simpleSettingValue'] ?? null;
if ($value === null && isset($setting['value']['value'])) {
$value = $setting['value']['value'];
}
if (is_array($value)) {
$value = json_encode($value, JSON_PRETTY_PRINT);
}
$entries[] = [
'key' => $key,
'value' => $value,
];
}
return [
'type' => 'keyValue',
'title' => $title,
'entries' => $entries,
];
}
/**
* @param array<int, mixed> $settings
* @return array{table: array<string, mixed>, warnings: array<int, string>}
*/
private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array
{
$flattened = $this->flattenSettingsCatalogSettingInstances($settings);
return [
'table' => [
'title' => $title,
'rows' => $flattened['rows'],
],
'warnings' => $flattened['warnings'],
];
}
/**
* @param array<int, mixed> $settings
* @return array{rows: array<int, array<string, mixed>>, warnings: array<int, string>}
*/
private function flattenSettingsCatalogSettingInstances(array $settings): array
{
$rows = [];
$warnings = [];
$rowCount = 0;
$warnedDepthLimit = false;
$warnedRowLimit = false;
// Extract all definition IDs first to resolve display names in batch
$definitionIds = $this->extractAllDefinitionIds($settings);
$definitions = $this->definitionResolver->resolve($definitionIds);
// Extract all category IDs and resolve them
$categoryIds = array_filter(array_unique(array_map(
fn ($def) => $def['categoryId'] ?? null,
$definitions
)));
$categories = $this->categoryResolver->resolve($categoryIds);
$categoryNames = [];
foreach ($categoryIds as $categoryId) {
$categoryName = $categories[$categoryId]['displayName'] ?? null;
if (is_string($categoryName) && $categoryName !== '') {
$categoryNames[] = $categoryName;
}
}
$categoryNames = array_values(array_unique($categoryNames));
$defaultCategoryName = count($categoryNames) === 1 ? $categoryNames[0] : null;
$walk = function (array $nodes, array $pathParts, int $depth) use (
&$walk,
&$rows,
&$warnings,
&$rowCount,
&$warnedDepthLimit,
&$warnedRowLimit,
$definitions,
$categories,
$defaultCategoryName
): void {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
if (! $warnedRowLimit) {
$warnings[] = sprintf('Settings truncated after %d rows.', self::SETTINGS_CATALOG_MAX_ROWS);
$warnedRowLimit = true;
}
return;
}
if ($depth > self::SETTINGS_CATALOG_MAX_DEPTH) {
if (! $warnedDepthLimit) {
$warnings[] = sprintf('Settings nesting truncated after depth %d.', self::SETTINGS_CATALOG_MAX_DEPTH);
$warnedDepthLimit = true;
}
return;
}
foreach ($nodes as $node) {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
break;
}
if (! is_array($node)) {
continue;
}
$instance = $this->extractSettingsCatalogSettingInstance($node);
$definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance);
$instanceType = is_array($instance) ? ($instance['@odata.type'] ?? $node['@odata.type'] ?? null) : ($node['@odata.type'] ?? null);
$rawInstanceType = is_string($instanceType) ? ltrim($instanceType, '#') : null;
$currentPathParts = array_merge($pathParts, [$definitionId]);
$path = implode(' > ', $currentPathParts);
$value = $this->extractSettingsCatalogValue($node, $instance);
// Get metadata from resolved definitions
$definition = $definitions[$definitionId] ?? null;
$displayName = $definition['displayName'] ??
$this->definitionResolver->prettifyDefinitionId($definitionId);
$categoryId = $definition['categoryId'] ?? null;
$categoryName = $categoryId ? ($categories[$categoryId]['displayName'] ?? '-') : '-';
$description = $definition['description'] ?? null;
if (! $categoryId && ! empty($pathParts)) {
foreach (array_reverse($pathParts) as $ancestorDefinitionId) {
if (! is_string($ancestorDefinitionId) || $ancestorDefinitionId === '') {
continue;
}
$ancestorDefinition = $definitions[$ancestorDefinitionId] ?? null;
$ancestorCategoryId = $ancestorDefinition['categoryId'] ?? null;
if ($ancestorCategoryId) {
$categoryId = $ancestorCategoryId;
$categoryName = $categories[$categoryId]['displayName'] ?? '-';
break;
}
}
}
if (
! $categoryId
&& $defaultCategoryName
&& (str_contains($definitionId, '{') || str_contains($definitionId, '}'))
) {
$categoryName = $defaultCategoryName;
}
// Convert technical type to user-friendly data type
$dataType = $this->getUserFriendlyDataType($rawInstanceType, $value);
$rows[] = [
'definition' => $displayName,
'definition_id' => $definitionId,
'category' => $categoryName,
'data_type' => $dataType,
'value' => $this->stringifySettingsCatalogValue($value),
'description' => $description ? Str::limit($description, 100) : '-',
'path' => $path,
'raw' => $this->pruneSettingsCatalogRaw($instance ?? $node),
];
$rowCount++;
if (! is_array($instance)) {
continue;
}
$nested = $this->extractSettingsCatalogChildren($instance);
if (! empty($nested)) {
$walk($nested, $currentPathParts, $depth + 1);
}
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) {
$collections = $instance['groupSettingCollectionValue'] ?? [];
if (! is_array($collections)) {
continue;
}
foreach (array_values($collections) as $index => $collection) {
if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) {
break;
}
if (! is_array($collection)) {
continue;
}
$children = $collection['children'] ?? [];
if (! is_array($children) || empty($children)) {
continue;
}
$walk($children, array_merge($currentPathParts, ['['.($index + 1).']']), $depth + 1);
}
}
}
};
$walk($settings, [], 1);
return [
'rows' => array_slice($rows, 0, self::SETTINGS_CATALOG_MAX_ROWS),
'warnings' => $warnings,
];
}
private function extractSettingsCatalogSettingInstance(array $setting): ?array
{
$instance = $setting['settingInstance'] ?? null;
if (is_array($instance)) {
return $instance;
}
if (isset($setting['@odata.type']) && (isset($setting['settingDefinitionId']) || isset($setting['definitionId']))) {
return $setting;
}
return null;
}
private function extractSettingsCatalogDefinitionId(array $setting, ?array $instance): string
{
$candidates = [
$setting['definitionId'] ?? null,
$setting['settingDefinitionId'] ?? null,
$setting['name'] ?? null,
$setting['displayName'] ?? null,
$instance['settingDefinitionId'] ?? null,
$instance['definitionId'] ?? null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}
return 'setting';
}
private function formatSettingsCatalogInstanceType(?string $type): ?string
{
if (! $type) {
return null;
}
$type = Str::afterLast($type, '.');
foreach (['deviceManagementConfiguration', 'deviceManagement'] as $prefix) {
if (Str::startsWith($type, $prefix)) {
$type = substr($type, strlen($prefix));
break;
}
}
return $type !== '' ? $type : null;
}
private function isSettingsCatalogGroupSettingCollectionInstance(array $instance): bool
{
$type = $instance['@odata.type'] ?? null;
if (! is_string($type)) {
return false;
}
return Str::contains($type, 'GroupSettingCollectionInstance', ignoreCase: true);
}
/**
* @return array<int, mixed>
*/
private function extractSettingsCatalogChildren(array $instance): array
{
foreach (['children', 'choiceSettingValue.children', 'groupSettingValue.children'] as $path) {
$children = Arr::get($instance, $path);
if (is_array($children) && ! empty($children)) {
return $children;
}
}
return [];
}
private function extractSettingsCatalogValue(array $setting, ?array $instance): mixed
{
if ($instance === null) {
return $setting['value'] ?? null;
}
$type = $instance['@odata.type'] ?? null;
$type = is_string($type) ? $type : '';
if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) {
$simple = $instance['simpleSettingValue'] ?? null;
if (is_array($simple)) {
return $simple['value'] ?? $simple;
}
return $simple;
}
if (Str::contains($type, 'ChoiceSettingInstance', ignoreCase: true)) {
$choice = $instance['choiceSettingValue'] ?? null;
if (is_array($choice)) {
return $choice['value'] ?? $choice;
}
return $choice;
}
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance) || Str::contains($type, 'GroupSettingInstance', ignoreCase: true)) {
return '(group)';
}
$fallback = $instance;
unset($fallback['children']);
return $fallback;
}
private function stringifySettingsCatalogValue(mixed $value): string
{
if ($value === null) {
return '-';
}
return $this->formatSettingsCatalogValue($value);
}
private function pruneSettingsCatalogRaw(mixed $raw): mixed
{
if (! is_array($raw)) {
return $raw;
}
$pruned = $raw;
unset($pruned['children'], $pruned['groupSettingCollectionValue']);
return $pruned;
}
private function normalizeStandard(array $snapshot): array
{
$metadataKeys = [
'@odata.context',
'@odata.type',
'id',
'version',
'createdDateTime',
'lastModifiedDateTime',
'supportsScopeTags',
'roleScopeTagIds',
'assignments',
'createdBy',
'lastModifiedBy',
'omaSettings',
'settings',
'settingsDelta',
];
$filtered = Arr::except($snapshot, $metadataKeys);
$entries = [];
foreach ($filtered as $key => $value) {
if (is_array($value)) {
$value = json_encode($value, JSON_PRETTY_PRINT);
}
$entries[] = [
'key' => Str::headline((string) $key),
'value' => $value,
];
}
return [
'type' => 'keyValue',
'title' => 'General',
'entries' => $entries,
];
}
/**
* Normalize Settings Catalog policy with grouped, readable settings (T011-T014).
*
* @param array<int, mixed> $settings
* @return array{type: string, groups: array<int, array<string, mixed>>}
*/
public function normalizeSettingsCatalogGrouped(array $settings): array
{
// Extract all definition IDs
$definitionIds = $this->extractAllDefinitionIds($settings);
// Resolve definitions
$definitions = $this->definitionResolver->resolve($definitionIds);
// Flatten settings
$flattened = $this->flattenSettingsCatalogForGrouping($settings);
// Group by category
$groups = $this->groupSettingsByCategory($flattened, $definitions);
return [
'type' => 'settings_catalog_grouped',
'groups' => $groups,
];
return $this->defaultNormalizer->normalizeSettingsCatalogGrouped($settings);
}
/**
* Extract all definition IDs from settings array recursively.
*/
private function extractAllDefinitionIds(array $settings): array
private function resolveNormalizer(string $policyType): PolicyTypeNormalizer
{
$ids = [];
foreach ($settings as $setting) {
// Top-level settings have settingInstance wrapper
if (isset($setting['settingInstance']['settingDefinitionId'])) {
$ids[] = $setting['settingInstance']['settingDefinitionId'];
$instance = $setting['settingInstance'];
}
// Nested children have settingDefinitionId directly (they ARE the instance)
elseif (isset($setting['settingDefinitionId'])) {
$ids[] = $setting['settingDefinitionId'];
$instance = $setting;
} else {
continue;
}
// Handle nested children using the comprehensive children extraction method
$children = $this->extractSettingsCatalogChildren($instance);
if (! empty($children)) {
$childIds = $this->extractAllDefinitionIds($children);
$ids = array_merge($ids, $childIds);
}
// Also handle nested children in group collections (fallback for legacy code)
if (isset($instance['groupSettingCollectionValue'])) {
foreach ($instance['groupSettingCollectionValue'] as $group) {
if (isset($group['children']) && is_array($group['children'])) {
$childIds = $this->extractAllDefinitionIds($group['children']);
$ids = array_merge($ids, $childIds);
}
}
foreach ($this->typeNormalizers as $normalizer) {
if ($normalizer->supports($policyType)) {
return $normalizer;
}
}
return array_unique($ids);
}
/**
* Flatten settings for grouping with value formatting.
*/
private function flattenSettingsCatalogForGrouping(array $settings): array
{
$rows = [];
$walk = function (array $nodes, array $pathParts) use (&$walk, &$rows): void {
foreach ($nodes as $node) {
if (! is_array($node)) {
continue;
}
$instance = $this->extractSettingsCatalogSettingInstance($node);
$definitionId = $this->extractSettingsCatalogDefinitionId($node, $instance);
$value = $this->extractSettingsCatalogValue($node, $instance);
$isGroupCollection = $this->isSettingsCatalogGroupSettingCollectionInstance($instance);
// Only add to rows if NOT a group collection (those are containers)
if (! $isGroupCollection) {
$rows[] = [
'definition_id' => $definitionId,
'value_raw' => $value,
'value_display' => $this->formatSettingsCatalogValue($value),
'instance_type' => is_array($instance) ? ($instance['@odata.type'] ?? null) : null,
];
}
// Handle nested children
if (is_array($instance)) {
$nested = $this->extractSettingsCatalogChildren($instance);
if (! empty($nested)) {
$walk($nested, array_merge($pathParts, [$definitionId]));
}
// Handle group collections
if ($this->isSettingsCatalogGroupSettingCollectionInstance($instance)) {
$collections = $instance['groupSettingCollectionValue'] ?? [];
if (is_array($collections)) {
foreach ($collections as $collection) {
if (isset($collection['children']) && is_array($collection['children'])) {
$walk($collection['children'], array_merge($pathParts, [$definitionId]));
}
}
}
}
}
}
};
$walk($settings, []);
return $rows;
}
/**
* Format setting value for display (T012).
*/
private function formatSettingsCatalogValue(mixed $value): string
{
if (is_bool($value)) {
return $value ? 'Enabled' : 'Disabled';
}
if (is_int($value)) {
return number_format($value);
}
if (is_string($value)) {
// Remove {tenantid} placeholder
$value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value);
$value = preg_replace('/_+/', '_', $value);
// Extract choice label from choice values (last meaningful part)
// Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0"
if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) {
$parts = explode('_', $value);
$lastPart = end($parts);
// Check for boolean-like values
if (in_array(strtolower($lastPart), ['true', 'false'])) {
return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled';
}
// If last part is just a number, take second-to-last too
if (is_numeric($lastPart) && count($parts) > 1) {
$secondLast = $parts[count($parts) - 2];
// Map common values
$mapping = [
'lowercaseletters' => 'Lowercase Letters',
'uppercaseletters' => 'Uppercase Letters',
'specialcharacters' => 'Special Characters',
'digits' => 'Digits',
];
if (isset($mapping[strtolower($secondLast)])) {
return $mapping[strtolower($secondLast)].': '.$lastPart;
}
if (in_array((string) $lastPart, ['0', '1'], true)) {
return (string) $lastPart === '1' ? 'Enabled' : 'Disabled';
}
return Str::title($secondLast).': '.$lastPart;
}
return Str::title($lastPart);
}
// Truncate long strings
return Str::limit($value, 100);
}
if (is_array($value)) {
return json_encode($value);
}
return (string) $value;
}
/**
* Group settings by category (T013).
*/
private function groupSettingsByCategory(array $rows, array $definitions): array
{
$grouped = [];
foreach ($rows as $row) {
$definitionId = $row['definition_id'];
$definition = $definitions[$definitionId] ?? null;
// Determine category
$categoryId = $definition['categoryId'] ?? $this->extractCategoryFromDefinitionId($definitionId);
$categoryTitle = $this->formatCategoryTitle($categoryId);
if (! isset($grouped[$categoryId])) {
$grouped[$categoryId] = [
'title' => $categoryTitle,
'description' => null,
'settings' => [],
];
}
$grouped[$categoryId]['settings'][] = [
'label' => $definition['displayName'] ?? $row['definition_id'],
'value' => $row['value_display'], // Primary value for display
'value_display' => $row['value_display'],
'value_raw' => $row['value_raw'],
'help_text' => $definition['helpText'] ?? $definition['description'] ?? null,
'definition_id' => $definitionId,
'instance_type' => $row['instance_type'],
'is_fallback' => $definition['isFallback'] ?? false,
];
}
// Sort groups by title
uasort($grouped, fn ($a, $b) => strcmp($a['title'], $b['title']));
// Sort settings within each group by label for stable ordering
foreach ($grouped as $cid => $g) {
if (isset($g['settings']) && is_array($g['settings'])) {
usort($g['settings'], function ($a, $b) {
return strcmp(strtolower($a['label'] ?? ''), strtolower($b['label'] ?? ''));
});
$grouped[$cid]['settings'] = $g['settings'];
}
}
return array_values($grouped);
}
/**
* Extract category from definition ID (fallback grouping).
*/
private function extractCategoryFromDefinitionId(string $definitionId): string
{
$parts = explode('_', $definitionId);
// Use first 2-3 segments as category
return implode('_', array_slice($parts, 0, min(3, count($parts))));
}
/**
* Format category ID into readable title.
*/
private function formatCategoryTitle(string $categoryId): string
{
// Try to prettify known patterns
if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-/i', $categoryId)) {
// It's a UUID - likely a category ID from Graph
return 'Additional Settings';
}
// Clean up common prefixes
$title = str_replace('device_vendor_msft_', '', $categoryId);
$title = Str::title(str_replace('_', ' ', $title));
// Known mappings
$mappings = [
'Passportforwork' => 'Windows Hello for Business',
];
foreach ($mappings as $search => $replace) {
$title = str_replace($search, $replace, $title);
}
return $title;
}
/**
* Convert technical instance type to user-friendly data type.
*/
private function getUserFriendlyDataType(?string $instanceType, mixed $value): string
{
if (! $instanceType) {
return $this->guessDataTypeFromValue($value);
}
$type = strtolower($instanceType);
if (str_contains($type, 'choice')) {
return 'Choice';
}
if (str_contains($type, 'simplesetting')) {
return $this->guessDataTypeFromValue($value);
}
if (str_contains($type, 'groupsetting')) {
return 'Group';
}
return 'Text';
}
/**
* Guess data type from value.
*/
private function guessDataTypeFromValue(mixed $value): string
{
if (is_bool($value)) {
return 'Boolean';
}
if (is_int($value)) {
return 'Number';
}
if (is_string($value)) {
// Check if it's a boolean-like string
if (in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'])) {
return 'Boolean';
}
// Check if numeric string
if (is_numeric($value)) {
return 'Number';
}
return 'Text';
}
if (is_array($value)) {
return 'List';
}
return 'Text';
return $this->defaultNormalizer;
}
}

View File

@ -36,11 +36,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
foreach ($types as $typeConfig) {
$policyType = $typeConfig['type'];
$platform = $typeConfig['platform'] ?? null;
$filter = $typeConfig['filter'] ?? null;
$this->graphLogger->logRequest('list_policies', [
'tenant' => $tenantIdentifier,
'policy_type' => $policyType,
'platform' => $platform,
'filter' => $filter,
]);
try {
@ -49,6 +51,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr
'client_id' => $tenant->app_client_id,
'client_secret' => $tenant->app_client_secret,
'platform' => $platform,
'filter' => $filter,
]);
} catch (Throwable $throwable) {
throw GraphErrorMapper::fromThrowable($throwable, [

View File

@ -0,0 +1,18 @@
<?php
namespace App\Services\Intune;
interface PolicyTypeNormalizer
{
public function supports(string $policyType): bool;
/**
* @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;
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array;
}

View File

@ -43,9 +43,16 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
[$foundationItems, $policyItems] = $this->splitItems($items);
$notificationTemplateIds = $foundationItems
->where('policy_type', 'notificationMessageTemplate')
->pluck('policy_identifier')
->filter()
->values()
->all();
$foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? [];
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) {
$policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant, $notificationTemplateIds) {
$existing = Policy::query()
->where('tenant_id', $tenant->id)
->where('external_id', $item->policy_identifier)
@ -54,7 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
$restoreMode = $this->resolveRestoreMode($item->policy_type);
return [
$preview = [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
'policy_type' => $item->policy_type,
@ -68,6 +75,18 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
$item->platform
) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null),
];
if ($item->policy_type === 'deviceCompliancePolicy') {
$preview = array_merge(
$preview,
$this->previewComplianceNotificationTemplates(
payload: is_array($item->payload) ? $item->payload : [],
availableTemplateIds: $notificationTemplateIds
)
);
}
return $preview;
})->all();
return array_merge($foundationPreview, $policyPreview);
@ -201,6 +220,16 @@ public function execute(
try {
$originalPayload = is_array($item->payload) ? $item->payload : [];
$originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping);
$complianceActionSummary = null;
$complianceActionOutcomes = null;
if ($item->policy_type === 'deviceCompliancePolicy') {
[$originalPayload, $complianceActionSummary, $complianceActionOutcomes] = $this->applyComplianceNotificationTemplateMapping(
payload: $originalPayload,
templateMapping: $foundationMappingByType['notificationMessageTemplate'] ?? []
);
}
$mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']);
// sanitize high-level fields according to contract
@ -247,7 +276,7 @@ public function execute(
settings: $settings,
graphOptions: $graphOptions,
context: $context,
fallbackName: $item->policy_identifier,
fallbackName: $item->resolvedDisplayName(),
);
if ($createOutcome['success']) {
@ -301,6 +330,26 @@ public function execute(
$payload,
$graphOptions
);
if ($item->policy_type === 'windowsAutopilotDeploymentProfile' && $response->failed()) {
$createOutcome = $this->createAutopilotDeploymentProfileIfMissing(
originalPayload: $originalPayload,
graphOptions: $graphOptions,
context: $context,
policyId: $item->policy_identifier,
);
if ($createOutcome['attempted']) {
$response = $createOutcome['response'] ?? $response;
if ($createOutcome['success']) {
$createdPolicyId = $createOutcome['policy_id'];
$createdPolicyMode = 'created';
$itemStatus = 'applied';
$resultReason = 'Policy missing; created new Autopilot profile.';
}
}
}
}
} catch (Throwable $throwable) {
$mapped = GraphErrorMapper::fromThrowable($throwable, $context);
@ -336,6 +385,7 @@ public function execute(
$assignmentOutcomes = null;
$assignmentSummary = null;
$restoredAssignments = null;
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
@ -358,6 +408,38 @@ public function execute(
$itemStatus = 'partial';
$resultReason = 'Assignments restored with failures';
}
}
if (is_array($assignmentOutcomes)) {
$restoredAssignments = collect($assignmentOutcomes['outcomes'] ?? [])
->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'success')
->pluck('assignment')
->filter()
->values()
->all();
if ($restoredAssignments === []) {
$restoredAssignments = null;
}
}
if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial';
$resultReason = 'Compliance notification actions skipped';
}
if ($complianceActionSummary !== null) {
$this->auditComplianceActionMapping(
tenant: $tenant,
restoreRun: $restoreRun,
policyId: $item->policy_identifier,
policyType: $item->policy_type,
summary: $complianceActionSummary,
outcomes: $complianceActionOutcomes ?? [],
actorEmail: $actorEmail,
actorName: $actorName
);
}
$result = $context + [
@ -391,6 +473,14 @@ public function execute(
$result['assignment_summary'] = $assignmentSummary;
}
if ($complianceActionSummary !== null) {
$result['compliance_action_summary'] = $complianceActionSummary;
}
if ($complianceActionOutcomes !== null) {
$result['compliance_action_outcomes'] = $complianceActionOutcomes;
}
$results[] = $result;
$appliedPolicyId = $item->policy_identifier;
@ -410,7 +500,8 @@ public function execute(
'source' => 'restore',
'restore_run_id' => $restoreRun->id,
'backup_item_id' => $item->id,
]
],
assignments: $restoredAssignments,
);
}
}
@ -599,6 +690,230 @@ private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds,
return $payload;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $availableTemplateIds
* @return array<string, mixed>
*/
private function previewComplianceNotificationTemplates(array $payload, array $availableTemplateIds): array
{
$templateIds = $this->collectComplianceNotificationTemplateIds($payload);
if ($templateIds === []) {
return [];
}
$available = array_values(array_unique($availableTemplateIds));
$missing = array_values(array_diff($templateIds, $available));
$summary = [
'total' => count($templateIds),
'missing' => count($missing),
];
$warning = null;
if ($missing !== []) {
$warning = sprintf('Missing %d notification template(s); notification actions may be skipped.', count($missing));
}
return array_filter([
'compliance_action_summary' => $summary,
'compliance_action_warning' => $warning,
'compliance_action_missing_templates' => $missing !== [] ? $missing : null,
], static fn ($value) => $value !== null);
}
/**
* @param array<string, mixed> $payload
* @param array<string, string> $templateMapping
* @return array{0: array<string, mixed>, 1: ?array{total:int,mapped:int,skipped:int}, 2: ?array<int, array<string, mixed>>}
*/
private function applyComplianceNotificationTemplateMapping(array $payload, array $templateMapping): array
{
$scheduled = $payload['scheduledActionsForRule'] ?? null;
if (! is_array($scheduled)) {
return [$payload, null, null];
}
$rules = [];
$total = 0;
$mapped = 0;
$skipped = 0;
$outcomes = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
$rules[] = $rule;
continue;
}
$ruleName = $rule['ruleName'] ?? null;
$updatedConfigs = [];
foreach ($configs as $config) {
if (! is_array($config)) {
$updatedConfigs[] = $config;
continue;
}
$actionType = $config['actionType'] ?? null;
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($actionType !== 'notification' || $templateKey === null) {
$updatedConfigs[] = $config;
continue;
}
$templateId = $config[$templateKey] ?? null;
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
$updatedConfigs[] = $config;
continue;
}
$total++;
if ($templateMapping === []) {
$outcomes[] = [
'status' => 'skipped',
'template_id' => $templateId,
'rule_name' => $ruleName,
'reason' => 'Notification template mapping unavailable.',
];
$skipped++;
continue;
}
$mappedTemplateId = $templateMapping[$templateId] ?? null;
if (! is_string($mappedTemplateId) || $mappedTemplateId === '') {
$outcomes[] = [
'status' => 'skipped',
'template_id' => $templateId,
'rule_name' => $ruleName,
'reason' => 'Notification template mapping missing for template ID.',
];
$skipped++;
continue;
}
$config[$templateKey] = $mappedTemplateId;
$updatedConfigs[] = $config;
$mapped++;
$outcomes[] = [
'status' => 'mapped',
'template_id' => $templateId,
'mapped_template_id' => $mappedTemplateId,
'rule_name' => $ruleName,
];
}
if ($updatedConfigs === []) {
continue;
}
$rule['scheduledActionConfigurations'] = array_values($updatedConfigs);
$rules[] = $rule;
}
if ($rules !== []) {
$payload['scheduledActionsForRule'] = array_values($rules);
} else {
unset($payload['scheduledActionsForRule']);
}
if ($total === 0) {
return [$payload, null, null];
}
return [$payload, ['total' => $total, 'mapped' => $mapped, 'skipped' => $skipped], $outcomes];
}
/**
* @param array<string, mixed> $payload
* @return array<int, string>
*/
private function collectComplianceNotificationTemplateIds(array $payload): array
{
$scheduled = $payload['scheduledActionsForRule'] ?? null;
if (! is_array($scheduled)) {
return [];
}
$ids = [];
foreach ($scheduled as $rule) {
if (! is_array($rule)) {
continue;
}
$configs = $rule['scheduledActionConfigurations'] ?? null;
if (! is_array($configs)) {
continue;
}
foreach ($configs as $config) {
if (! is_array($config)) {
continue;
}
if (($config['actionType'] ?? null) !== 'notification') {
continue;
}
$templateKey = $this->resolveNotificationTemplateKey($config);
if ($templateKey === null) {
continue;
}
$templateId = $config[$templateKey] ?? null;
if (! is_string($templateId) || $templateId === '' || $this->isEmptyGuid($templateId)) {
continue;
}
$ids[] = $templateId;
}
}
return array_values(array_unique($ids));
}
private function resolveNotificationTemplateKey(array $config): ?string
{
if (array_key_exists('notificationTemplateId', $config)) {
return 'notificationTemplateId';
}
if (array_key_exists('notificationMessageTemplateId', $config)) {
return 'notificationMessageTemplateId';
}
return null;
}
private function isEmptyGuid(string $value): bool
{
return strtolower($value) === '00000000-0000-0000-0000-000000000000';
}
/**
* @param array<int, array<string, mixed>> $entries
*/
@ -653,6 +968,51 @@ private function auditFoundationMapping(
}
}
/**
* @param array{total:int,mapped:int,skipped:int} $summary
* @param array<int, array<string, mixed>> $outcomes
*/
private function auditComplianceActionMapping(
Tenant $tenant,
RestoreRun $restoreRun,
string $policyId,
string $policyType,
array $summary,
array $outcomes,
?string $actorEmail,
?string $actorName
): void {
$skipped = (int) ($summary['skipped'] ?? 0);
$status = $skipped > 0 ? 'warning' : 'success';
$skippedTemplates = collect($outcomes)
->filter(fn (array $outcome) => ($outcome['status'] ?? null) === 'skipped')
->pluck('template_id')
->filter()
->values()
->all();
$this->auditLogger->log(
tenant: $tenant,
action: 'restore.compliance.actions.mapped',
context: [
'metadata' => [
'restore_run_id' => $restoreRun->id,
'policy_id' => $policyId,
'policy_type' => $policyType,
'total' => (int) ($summary['total'] ?? 0),
'mapped' => (int) ($summary['mapped'] ?? 0),
'skipped' => $skipped,
'skipped_template_ids' => $skippedTemplates,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $restoreRun->id,
status: $status
);
}
/**
* @param array<int>|null $selectedItemIds
*/
@ -999,6 +1359,96 @@ private function createSettingsCatalogPolicy(
];
}
/**
* @return array{attempted:bool,success:bool,policy_id:?string,response:?object}
*/
private function createAutopilotDeploymentProfileIfMissing(
array $originalPayload,
array $graphOptions,
array $context,
string $policyId,
): array {
if (! $this->shouldAttemptAutopilotCreate($policyId, $graphOptions)) {
return [
'attempted' => false,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile')
?? 'deviceManagement/windowsAutopilotDeploymentProfiles';
$payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload);
$payload['displayName'] = $this->prefixRestoredName(
$this->resolvePayloadString($payload, ['displayName', 'name']),
$policyId
);
unset($payload['name']);
if ($payload === []) {
return [
'attempted' => true,
'success' => false,
'policy_id' => null,
'response' => null,
];
}
$this->graphLogger->logRequest('create_autopilot_profile', $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$response = $this->graphClient->request(
'POST',
$resource,
['json' => $payload] + Arr::except($graphOptions, ['platform'])
);
$this->graphLogger->logResponse('create_autopilot_profile', $response, $context + [
'endpoint' => $resource,
'method' => 'POST',
]);
$policyId = $this->extractCreatedPolicyId($response);
return [
'attempted' => true,
'success' => $response->successful(),
'policy_id' => $policyId,
'response' => $response,
];
}
private function shouldAttemptAutopilotCreate(string $policyId, array $graphOptions): bool
{
$response = $this->graphClient->getPolicy(
'windowsAutopilotDeploymentProfile',
$policyId,
$graphOptions
);
if ($response->successful()) {
return false;
}
if ($response->status === 404) {
return true;
}
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
$message = strtolower((string) ($response->meta['error_message'] ?? ''));
if (str_contains($code, 'notfound') || str_contains($code, 'resource')) {
return true;
}
return str_contains($message, 'not found')
|| str_contains($message, 'resource not found')
|| str_contains($message, 'does not exist');
}
private function shouldRetrySettingsCatalogCreateWithoutSettings(object $response): bool
{
$code = strtolower((string) ($response->meta['error_code'] ?? ''));
@ -1033,7 +1483,7 @@ private function buildSettingsCatalogCreatePayload(
$payload = [];
$name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']);
$payload['name'] = $name ?? sprintf('Restored %s', $fallbackName);
$payload['name'] = $this->prefixRestoredName($name, $fallbackName);
$description = $this->resolvePayloadString($originalPayload, ['description', 'Description']);
if ($description !== null) {
@ -1075,6 +1525,24 @@ private function buildSettingsCatalogCreatePayload(
return $payload;
}
private function prefixRestoredName(?string $name, string $fallback): string
{
$prefix = 'Restored_';
$base = trim((string) ($name ?? $fallback));
if ($base === '') {
$base = $fallback;
}
$normalized = strtolower($base);
if (str_starts_with($normalized, 'restored_') || str_starts_with($normalized, 'restored ')) {
return $base;
}
return $prefix.$base;
}
/**
* @param array<string, mixed> $payload
* @param array<int, string> $keys

View File

@ -0,0 +1,31 @@
<?php
namespace App\Services\Intune;
class SettingsCatalogPolicyNormalizer implements PolicyTypeNormalizer
{
public function __construct(
private readonly DefaultPolicyNormalizer $defaultNormalizer,
) {}
public function supports(string $policyType): bool
{
return $policyType === 'settingsCatalogPolicy';
}
/**
* @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
{
return $this->defaultNormalizer->normalize($snapshot, $policyType, $platform);
}
/**
* @return array<string, mixed>
*/
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return $this->defaultNormalizer->flattenForDiff($snapshot, $policyType, $platform);
}
}

View File

@ -91,7 +91,15 @@ public function captureFromGraph(
if ($includeAssignments) {
try {
$rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions);
$rawAssignments = $this->assignmentFetcher->fetch(
$policy->policy_type,
$tenantIdentifier,
$policy->external_id,
$graphOptions,
true
);
$assignmentMetadata['assignments_fetched'] = true;
$assignmentMetadata['assignments_count'] = count($rawAssignments);
if (! empty($rawAssignments)) {
$resolvedGroups = [];
@ -110,14 +118,8 @@ public function captureFromGraph(
$assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups)
->contains(fn (array $group) => $group['orphaned'] ?? false);
$assignmentMetadata['assignments_count'] = count($rawAssignments);
$filterIds = collect($rawAssignments)
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filterIds = $this->extractAssignmentFilterIds($rawAssignments);
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters)
@ -170,9 +172,21 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
}
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
if ($filterId && isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($target['deviceAndAppManagementAssignmentFilterId'] ?? null);
$filterType = $assignment['deviceAndAppManagementAssignmentFilterType']
?? ($target['deviceAndAppManagementAssignmentFilterType'] ?? null);
if ($filterId) {
$target['deviceAndAppManagementAssignmentFilterId'] = $target['deviceAndAppManagementAssignmentFilterId'] ?? $filterId;
if ($filterType) {
$target['deviceAndAppManagementAssignmentFilterType'] = $target['deviceAndAppManagementAssignmentFilterType'] ?? $filterType;
}
if (isset($filterNames[$filterId])) {
$target['assignment_filter_name'] = $filterNames[$filterId];
}
}
$assignment['target'] = $target;
@ -181,6 +195,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
}, $assignments);
}
/**
* @param array<int, array<string, mixed>> $assignments
* @return array<int, string>
*/
private function extractAssignmentFilterIds(array $assignments): array
{
$filterIds = [];
foreach ($assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
?? ($assignment['target']['deviceAndAppManagementAssignmentFilterId'] ?? null);
if (is_string($filterId) && $filterId !== '') {
$filterIds[] = $filterId;
}
}
return array_values(array_unique($filterIds));
}
/**
* @param array<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -17,10 +17,18 @@ protected static function odataTypeMap(): array
'macOS' => '#microsoft.graph.macOSGeneralDeviceConfiguration',
'all' => '#microsoft.graph.deviceConfiguration',
],
'groupPolicyConfiguration' => [
'windows' => '#microsoft.graph.groupPolicyConfiguration',
'all' => '#microsoft.graph.groupPolicyConfiguration',
],
'settingsCatalogPolicy' => [
'windows' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'all' => '#microsoft.graph.deviceManagementConfigurationPolicy',
],
'windowsUpdateRing' => [
'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration',
],
'deviceCompliancePolicy' => [
'windows' => '#microsoft.graph.windows10CompliancePolicy',
'ios' => '#microsoft.graph.iosCompliancePolicy',
@ -38,6 +46,14 @@ protected static function odataTypeMap(): array
'deviceManagementScript' => [
'windows' => '#microsoft.graph.deviceManagementScript',
],
'deviceShellScript' => [
'macOS' => '#microsoft.graph.deviceShellScript',
'all' => '#microsoft.graph.deviceShellScript',
],
'deviceHealthScript' => [
'windows' => '#microsoft.graph.deviceHealthScript',
'all' => '#microsoft.graph.deviceHealthScript',
],
'enrollmentRestriction' => [
'all' => '#microsoft.graph.deviceEnrollmentConfiguration',
],

View File

@ -27,6 +27,34 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'groupPolicyConfiguration' => [
'resource' => 'deviceManagement/groupPolicyConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.groupPolicyConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/groupPolicyConfigurations/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'settingsCatalogPolicy' => [
'resource' => 'deviceManagement/configurationPolicies',
@ -84,6 +112,27 @@
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'windowsUpdateRing' => [
'resource' => 'deviceManagement/deviceConfigurations',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsUpdateForBusinessConfiguration',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceConfigurations/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'deviceCompliancePolicy' => [
'resource' => 'deviceManagement/deviceCompliancePolicies',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
@ -99,6 +148,15 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceCompliancePolicies/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
'supports_scope_tags' => true,
'scope_tag_field' => 'roleScopeTagIds',
],
'appProtectionPolicy' => [
'resource' => 'deviceAppManagement/managedAppPolicies',
@ -135,6 +193,54 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceManagementScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'deviceShellScript' => [
'resource' => 'deviceManagement/deviceShellScripts',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceShellScript',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceManagementScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'deviceHealthScript' => [
'resource' => 'deviceManagement/deviceHealthScripts',
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'],
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.deviceHealthScript',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceHealthScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'enrollmentRestriction' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
@ -148,6 +254,10 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'windowsAutopilotDeploymentProfile' => [
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
@ -155,11 +265,26 @@
'allowed_expand' => [],
'type_family' => [
'#microsoft.graph.windowsAutopilotDeploymentProfile',
'#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
'#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile',
],
'create_method' => 'POST',
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'strip_odata' => false,
'update_strip_keys' => [
'assignments',
'managementServiceAppId',
'outOfBoxExperienceSetting',
'hardwareHashExtractionEnabled',
'locale',
],
'assignments_list_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments',
'assignments_create_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments',
'assignments_create_method' => 'POST',
'assignments_delete_path' => '/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}',
'assignments_delete_method' => 'DELETE',
],
'windowsEnrollmentStatusPage' => [
'resource' => 'deviceManagement/deviceEnrollmentConfigurations',
@ -172,6 +297,10 @@
'update_method' => 'PATCH',
'id_field' => 'id',
'hydration' => 'properties',
'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'enrollmentConfigurationAssignments',
],
'endpointSecurityIntent' => [
'resource' => 'deviceManagement/intents',

View File

@ -77,8 +77,14 @@
[
'key' => 'DeviceManagementScripts.ReadWrite.All',
'type' => 'application',
'description' => 'Read directory data needed for tenant health checks.',
'features' => ['script-management'],
'description' => 'Manage Intune device management scripts and remediations.',
'features' => ['policy-sync', 'backup', 'restore', 'scripts', 'remediations'],
],
[
'key' => 'DeviceManagementScripts.Read.All',
'type' => 'application',
'description' => 'Read Intune device management scripts and remediations.',
'features' => ['policy-sync', 'backup', 'scripts', 'remediations'],
],
],
// Stub list of permissions already granted to the service principal (used for display in Tenant verification UI).

View File

@ -8,6 +8,17 @@
'category' => 'Configuration',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'groupPolicyConfiguration',
'label' => 'Administrative Templates',
'category' => 'Configuration',
'platform' => 'windows',
'endpoint' => 'deviceManagement/groupPolicyConfigurations',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
@ -22,6 +33,17 @@
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'windowsUpdateRing',
'label' => 'Software Update Ring',
'category' => 'Update Management',
'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceConfigurations',
'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium-high',
],
[
'type' => 'deviceCompliancePolicy',
'label' => 'Device Compliance',
@ -62,6 +84,26 @@
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'deviceShellScript',
'label' => 'macOS Shell Scripts',
'category' => 'Scripts',
'platform' => 'macOS',
'endpoint' => 'deviceManagement/deviceShellScripts',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'deviceHealthScript',
'label' => 'Proactive Remediations',
'category' => 'Scripts',
'platform' => 'windows',
'endpoint' => 'deviceManagement/deviceHealthScripts',
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',
],
[
'type' => 'enrollmentRestriction',
'label' => 'Enrollment Restrictions',
@ -88,7 +130,7 @@
'category' => 'Enrollment',
'platform' => 'all',
'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations',
'filter' => "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'",
'backup' => 'full',
'restore' => 'enabled',
'risk' => 'medium',

View File

@ -82,6 +82,12 @@
{{ $item['validation_warning'] }}
</div>
@endif
@if (! empty($item['compliance_action_warning']))
<div class="mt-2 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800">
{{ $item['compliance_action_warning'] }}
</div>
@endif
</div>
@endforeach
</div>

View File

@ -189,12 +189,59 @@
@endif
@endif
@if (! empty($item['compliance_action_summary']) && is_array($item['compliance_action_summary']))
@php
$summary = $item['compliance_action_summary'];
$complianceOutcomes = $item['compliance_action_outcomes'] ?? [];
$complianceIssues = collect($complianceOutcomes)
->filter(fn ($outcome) => ($outcome['status'] ?? null) === 'skipped')
->values();
@endphp
<div class="mt-2 text-xs text-gray-700">
Compliance notifications: {{ (int) ($summary['mapped'] ?? 0) }} mapped
{{ (int) ($summary['skipped'] ?? 0) }} skipped
</div>
@if ($complianceIssues->isNotEmpty())
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
<summary class="cursor-pointer font-semibold">Compliance notification details</summary>
<div class="mt-2 space-y-2">
@foreach ($complianceIssues as $outcome)
<div class="rounded border border-amber-200 bg-white p-2">
<div class="flex items-center justify-between">
<div class="font-semibold text-gray-900">
Template {{ $outcome['template_id'] ?? 'unknown' }}
</div>
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-900 bg-amber-100 border-amber-200">
skipped
</span>
</div>
@if (! empty($outcome['rule_name']))
<div class="mt-1 text-[11px] text-gray-700">
Rule: {{ $outcome['rule_name'] }}
</div>
@endif
@if (! empty($outcome['reason']))
<div class="mt-1 text-[11px] text-gray-800">
{{ $outcome['reason'] }}
</div>
@endif
</div>
@endforeach
</div>
</details>
@endif
@endif
@if (! empty($item['created_policy_id']))
@php
$createdMode = $item['created_policy_mode'] ?? null;
$createdMessage = $createdMode === 'metadata_only'
? 'New policy created (metadata only). Apply settings manually.'
: 'New policy created (manual cleanup required).';
$createdMessage = match ($createdMode) {
'metadata_only' => 'New policy created (metadata only). Apply settings manually.',
'created' => 'New policy created.',
default => 'New policy created (manual cleanup required).',
};
@endphp
<div class="mt-2 text-xs text-amber-800">
{{ $createdMessage }} ID: {{ $item['created_policy_id'] }}

View File

@ -118,9 +118,29 @@
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@php
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
$assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false;
$assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null;
@endphp
@if($assignmentsFetchFailed)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments could not be fetched from Microsoft Graph.
</p>
@if($assignmentsFetchError)
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
{{ $assignmentsFetchError }}
</p>
@endif
@elseif($assignmentsFetched)
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
No assignments found for this version.
</p>
@else
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@endif
@php
$hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments')

View File

@ -0,0 +1,79 @@
# Implementation Plan: Device Configuration and Compliance Coverage
**Branch**: `007-device-config-compliance` | **Date**: 2025-12-26 | **Spec**: ./spec.md
**Input**: Feature specification from `/specs/007-device-config-compliance/spec.md`
## Summary
Expand backup and restore coverage for device configuration, compliance, scripts, and update rings. This plan focuses on policy type coverage, assignment capture, and safe restore behavior using existing foundation mappings and assignment logic.
Phase outputs:
- Phase 0 research: n/a (no new research artifact yet)
- Phase 1 design: n/a (no new data model artifact yet)
## Technical Context
**Language/Version**: PHP 8.4 (Laravel 12)
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3, Microsoft Graph (custom client abstraction)
**Storage**: PostgreSQL (JSONB payload storage for snapshots)
**Testing**: Pest v4 + PHPUnit 12
**Target Platform**: Docker/Sail locally; container deploy via Dokploy
**Project Type**: Web application (Laravel backend + Filament admin UI)
**Performance Goals**: Restore preview for 100 selected items in under 2 minutes
**Constraints**: Restore must be defensive (no deletions); assignments only applied with valid mapping; audit logs required
**Scale/Scope**: Tenants with mixed configuration and compliance policies, including scripts and update rings
## Constitution Check
The constitution at `.specify/memory/constitution.md` is currently an unfilled template. For this feature, adopt the repo rules as gates:
- Sail-first local dev/test commands.
- Spec gate: code changes must be accompanied by `specs/007-device-config-compliance/` updates.
- Tests required for behavior changes (Pest).
- Restore safety: never delete; skip unsafe assignments; record reasons.
- Auditability: backup and restore outcomes are logged per tenant.
## Project Structure
### Documentation (this feature)
```text
specs/007-device-config-compliance/
├── spec.md
├── plan.md
└── tasks.md
```
### Source Code (expected touch points)
```text
app/
├── Filament/
│ └── Resources/
├── Models/
│ ├── BackupItem.php
│ ├── Policy.php
│ └── PolicyVersion.php
├── Services/
│ ├── Graph/
│ └── Intune/
└── Jobs/
config/
├── graph_contracts.php
├── intune_permissions.php
└── tenantpilot.php
tests/
├── Feature/
└── Unit/
```
**Structure Decision**: Extend existing services (PolicySnapshotService, PolicyCaptureOrchestrator, RestoreService) and Filament resources, adding only targeted helpers where needed.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| n/a | n/a | n/a |

View File

@ -0,0 +1,78 @@
# Feature Specification: Device Configuration and Compliance Coverage
**Feature Branch**: `007-device-config-compliance`
**Created**: 2025-12-26
**Status**: Draft
**Input**: Workload list for Intune backup and restore coverage (MVP vs full scope).
## Program Scope Reference (MVP vs Full)
### MVP Scope (Phase 1)
- Device configuration and compliance: administrative templates; settings catalog policies; device configurations (including custom OMA-URI); device compliance policies; assignments.
- Scripts and remediations: PowerShell scripts (Windows); macOS shell scripts (where supported); proactive remediations and assignments.
- Enrollment and Autopilot: Autopilot deployment profiles and assignments; Enrollment Status Page (ESP) if used.
- Update management (Windows): software update rings and assignments.
- Endpoint security: endpoint security configurations (antivirus, firewall, disk encryption, EDR, ASR, account protection) and assignments.
- Tenant administration foundations: assignment filters; scope tags; notification message templates.
### Full Scope (Phase 2+)
- Compliance actions and notifications: actions for noncompliance; compliance notifications and templates.
- Apps and app management: client apps; app protection policies; app configuration policies; assignments; supersedence metadata.
- Enrollment: enrollment restrictions; enrollment notifications; terms and conditions; ADE tokens and profiles.
- Update management: feature update policies; quality update policies; driver update policies; expedite/hotpatch policies.
- Endpoint security: security baselines (Windows security baseline, Microsoft Defender, Microsoft Edge); endpoint privilege management policies.
- Tenant administration: device cleanup rules; RBAC roles and role assignments.
- Connectors and tokens (metadata-only): APNs; VPP tokens; managed Google Play; certificate connectors; remote help settings.
- Inventory / Properties catalog policies (deviceManagement/inventoryPolicies) deferred until required permissions are confirmed.
## Overview
Expand backup and restore coverage for device configuration and compliance workloads, including scripts and remediations. This feature focuses on policy types that are already core to DR and rollback, and builds on existing foundations and assignment mapping capabilities.
## User Scenarios and Testing (mandatory)
### User Story 1 - Backup and Restore Core Configuration Policies (Priority: P1)
As an admin, I want to back up and restore device configuration and compliance policies with their assignments and scope tags, so that a restore reproduces targeting accurately.
**Independent Test**: Select at least one settings catalog policy, one device configuration policy (including an OMA-URI policy), and one device compliance policy. Create a backup with assignments and scope tags enabled. Restore into a tenant with different group IDs and verify assignments are mapped or skipped with clear reasons.
**Acceptance Scenarios**:
1. Given policies with assignments and scope tags, when a backup is captured, then assignments and scope tag metadata are stored alongside the snapshot.
2. Given a restore run with group mapping, when policies are restored, then assignments are applied using mapped group IDs and assignment filters.
3. Given missing mappings, when restore executes, then assignments are skipped and a human readable reason is recorded.
### User Story 2 - Compliance Actions and Notifications (Priority: P2)
As an admin, I want actions for noncompliance and compliance notification templates to be captured and restored, so that compliance workflows remain intact after restore.
**Independent Test**: Create a compliance policy with scheduled actions and a notification template. Capture a backup including foundations. Restore into a tenant without that template and verify the template is created and referenced correctly.
**Acceptance Scenarios**:
1. Given a compliance policy referencing a notification template, when restore executes, then the template is restored first and the policy references the mapped template ID.
2. Given a missing template and no mapping, when restore executes, then the policy is restored without that action and a skip reason is recorded.
### User Story 3 - Scripts and Remediations (Priority: P3)
As an admin, I want scripts and remediations to be captured and restored with assignments, so that endpoint automation is preserved.
**Independent Test**: Capture a PowerShell script and a proactive remediation with assignments. Restore into a test tenant and verify assignments are applied safely.
**Acceptance Scenarios**:
1. Given a script policy with assignments, when restore executes, then the script is recreated or updated and assignments are applied.
2. Given a remediation with missing assignment filter mapping, when restore executes, then the assignment is skipped and the remediation is still restored.
## Requirements (mandatory)
### Functional Requirements
- **FR-007.1**: System MUST support backup and restore for administrative templates, settings catalog policies, device configurations (including OMA-URI), and device compliance policies.
- **FR-007.2**: System MUST capture assignments and scope tags when the backup flags are enabled, using the existing capture orchestrator.
- **FR-007.3**: System MUST handle compliance actions and notification templates by restoring templates first and mapping references in policies.
- **FR-007.4**: System MUST restore scripts and remediations with assignments, applying foundation mappings and group mappings where available.
- **FR-007.5**: System MUST keep Conditional Access restore preview-only until identity dependency mapping is supported.
- **FR-007.6**: System MUST record audit logs for backup and restore actions, including skipped assignments and template mapping outcomes.
### Non-Goals
- No support for app workloads in this feature (tracked separately).
- No connector or token restore (metadata-only handled in a later phase).
## Success Criteria (mandatory)
- **SC-007.1**: For a backup containing at least 10 mixed configuration/compliance items, restore completes with 100% of items in Applied, Partial, or Skipped with reason (no silent failures).
- **SC-007.2**: At least 95% of assignments in a mixed restore are either applied successfully or explicitly skipped with a recorded reason.
- **SC-007.3**: Restore preview for 100 selected items completes in under 2 minutes in a typical admin environment.

View File

@ -0,0 +1,79 @@
# Tasks: Device Configuration and Compliance Coverage (007)
**Branch**: `feat/007-device-config-compliance` | **Date**: 2025-12-26
**Input**: [spec.md](./spec.md), [plan.md](./plan.md)
## Task Format
- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete
- **Task ID**: Sequential T001, T002, T003...
- **[P] marker**: Task can run in parallel (different files, no blocking dependencies)
- **[Story] label**: User story tag (US1, US2, US3...)
- **File path**: Always include exact file path in description
## Phase 1: Policy Types, Contracts, Permissions
**Purpose**: Add missing device configuration, compliance, scripts, and update ring types with Graph contract coverage.
- [x] T001 [P] Expand policy type registry for device configuration, compliance, scripts, and update rings in `config/tenantpilot.php` (labels, categories, restore mode, risk).
- [x] T002 [P] Add/update Graph contracts and assignment endpoints for new policy types in `config/graph_contracts.php`.
- [x] T003 [P] Verify and extend permissions for the new workloads in `config/intune_permissions.php`.
- [x] T004 Update type metadata helpers and filters in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`.
**Checkpoint**: New policy types are recognized across UI metadata and Graph contract registry.
---
## Phase 2: Snapshot Capture and Metadata
**Purpose**: Ensure snapshots, assignments, and scope tags are captured for the new workloads.
- [x] T005 Update `app/Services/Intune/PolicySnapshotService.php` to fetch and hydrate the new policy types correctly (filters, select fields).
- [x] T006 Extend `app/Services/Intune/PolicyCaptureOrchestrator.php` to capture assignments and scope tags for the new types with existing resolvers.
- [x] T007 Update `app/Services/Intune/BackupService.php` to capture snapshots for the new types and propagate warnings.
- [x] T008 Add or extend normalization support in `app/Services/Intune/PolicyNormalizer.php` for the new policy types.
**Checkpoint**: Backups include snapshots and metadata for configuration/compliance policies.
---
## Phase 3: Restore Logic and Mapping
**Purpose**: Restore new policy types safely using assignment and foundation mappings.
- [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
- [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types.
- [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`.
- [x] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.php`.
**Checkpoint**: Restore applies policies and assignments or skips with clear reasons.
---
## Phase 4: Admin UX
**Purpose**: Surface restore and compliance details clearly in the UI.
- [ ] T013 Update `resources/views/filament/infolists/entries/restore-preview.blade.php` to surface compliance action/template warnings.
- [ ] T014 Update `resources/views/filament/infolists/entries/restore-results.blade.php` to show compliance action mapping outcomes and skip reasons.
**Checkpoint**: Admins can see compliance related mapping results in preview and results.
---
## Phase 5: Tests and Verification
**Purpose**: Cover new workloads with Pest tests and verify formatting.
- [ ] T015 Add unit tests for snapshot and normalization coverage in `tests/Unit/PolicySnapshotServiceTest.php` and `tests/Unit/PolicyNormalizerTest.php`.
- [ ] T016 Add feature tests for backup and restore flows in `tests/Feature/Filament/RestorePreviewTest.php` and `tests/Feature/Filament/RestoreExecutionTest.php`.
- [ ] T017 Run tests: `./vendor/bin/sail artisan test tests/Unit/PolicySnapshotServiceTest.php tests/Unit/PolicyNormalizerTest.php tests/Feature/Filament/RestorePreviewTest.php tests/Feature/Filament/RestoreExecutionTest.php`
- [ ] T018 Run Pint: `./vendor/bin/pint --dirty`
**Checkpoint**: Tests pass and formatting is clean.
---
## Deferred / Backlog
- [ ] T019 [Deferred] Add inventory/properties catalog policies (`deviceManagement/inventoryPolicies`) once required permissions are confirmed; include contracts, sync, snapshot hydration via `/settings`, and normalized UI display.

View File

@ -19,7 +19,7 @@
// Mock PolicySnapshotService
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->twice() // Called once for each policy
->once() // Called once for the active policy
->andReturnUsing(function ($tenant, $policy) {
return [
'payload' => [
@ -96,6 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap
'display_name' => 'Policy B',
'platform' => 'windows',
'last_synced_at' => now(),
'ignored_at' => now(),
]);
$user = User::factory()->create();
@ -109,15 +110,15 @@ public function request(string $method, string $path, array $options = []): Grap
'ownerRecord' => $backupSet,
'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
])->callTableAction('addPolicies', data: [
'policy_ids' => [$policyA->id, $policyB->id],
'policy_ids' => [$policyA->id],
'include_assignments' => false,
'include_scope_tags' => true,
]);
$backupSet->refresh();
expect($backupSet->item_count)->toBe(2);
expect($backupSet->items)->toHaveCount(2);
expect($backupSet->item_count)->toBe(1);
expect($backupSet->items)->toHaveCount(1);
expect($backupSet->items->first()->payload['id'])->toBe('policy-1');
$firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id);
@ -140,3 +141,61 @@ public function request(string $method, string $path, array $options = []): Grap
'resource_id' => (string) $backupSet->id,
]);
});
test('backup service skips ignored policies', function () {
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->once()
->andReturnUsing(function ($tenant, $policy) {
return [
'payload' => [
'id' => $policy->external_id,
'name' => $policy->display_name,
'roleScopeTagIds' => ['0'],
],
'metadata' => [],
'warnings' => [],
];
});
});
$tenant = Tenant::create([
'name' => 'Test tenant',
'external_id' => 'tenant-1',
'tenant_id' => 'tenant-1',
'status' => 'active',
'metadata' => [],
]);
$tenant->makeCurrent();
$policyA = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-1',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Policy A',
'platform' => 'windows',
'last_synced_at' => now(),
]);
$policyB = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-2',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Policy B',
'platform' => 'windows',
'last_synced_at' => now(),
'ignored_at' => now(),
]);
$service = app(\App\Services\Intune\BackupService::class);
$backupSet = $service->createBackupSet(
tenant: $tenant,
policyIds: [$policyA->id, $policyB->id],
actorEmail: 'tester@example.com',
actorName: 'Tester',
);
expect($backupSet->item_count)->toBe(1);
expect($backupSet->items->pluck('policy_id')->all())->toBe([$policyA->id]);
});

View File

@ -193,7 +193,7 @@
// "Device Vendor Msft Policy Config Uncached Test Setting"
})->skip('Manual UI verification required');
it('does not show Settings tab for non-Settings Catalog policies', function () {
it('shows tabbed layout for non-Settings Catalog policies', function () {
$tenant = Tenant::create([
'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'),
'name' => 'Test Tenant',
@ -234,7 +234,9 @@
->get(PolicyResource::getUrl('view', ['record' => $policy]));
$response->assertOk();
// Verify page renders successfully for non-Settings Catalog policies
$response->assertSee('General');
$response->assertSee('Settings');
$response->assertSee('JSON');
});
// T034: Test display names shown (not definition IDs)

View File

@ -182,3 +182,213 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
'resource_id' => (string) $run->id,
]);
});
test('restore execution records compliance notification mapping outcomes', function () {
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
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 request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
});
$tenant = Tenant::create([
'tenant_id' => 'tenant-3',
'name' => 'Tenant Three',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-3',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'scheduledActionsForRule' => [
[
'ruleName' => 'Password',
'scheduledActionConfigurations' => [
[
'actionType' => 'notification',
'notificationTemplateId' => 'template-1',
],
],
],
],
],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
);
expect($run->status)->toBe('partial');
expect($run->results[0]['status'])->toBe('partial');
expect($run->results[0]['compliance_action_summary']['skipped'] ?? null)->toBe(1);
$this->assertDatabaseHas('audit_logs', [
'action' => 'restore.compliance.actions.mapped',
'resource_id' => (string) $run->id,
]);
});
test('restore execution creates an autopilot profile when missing', function () {
$graphClient = new class implements GraphClientInterface
{
public int $applyCalls = 0;
public int $getCalls = 0;
public int $createCalls = 0;
public array $createPayloads = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
$this->getCalls++;
return new GraphResponse(false, [], 404, [], [], [
'error_code' => 'ResourceNotFound',
'error_message' => 'Resource not found.',
]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyCalls++;
return new GraphResponse(false, [], 500, [], [], [
'error_code' => 'InternalServerError',
'error_message' => 'An internal server error has occurred.',
]);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) {
$this->createCalls++;
$this->createPayloads[] = $options['json'] ?? [];
return new GraphResponse(true, ['id' => 'autopilot-created']);
}
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
};
app()->instance(GraphClientInterface::class, $graphClient);
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-1',
'name' => 'Tenant One',
'metadata' => [],
]);
$backupSet = BackupSet::factory()->for($tenant)->create([
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => null,
'policy_identifier' => 'autopilot-1',
'policy_type' => 'windowsAutopilotDeploymentProfile',
'platform' => 'windows',
'payload' => [
'@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
'displayName' => 'Autopilot Profile',
'language' => 'en-US',
],
])
->create();
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
);
expect($graphClient->applyCalls)->toBe(1);
expect($graphClient->getCalls)->toBe(1);
expect($graphClient->createCalls)->toBe(1);
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile');
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');
});

View File

@ -22,6 +22,14 @@
'display_name' => 'Policy Display',
'platform' => 'windows',
]);
$ignoredPolicy = Policy::factory()->create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-ignored',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Ignored Policy',
'platform' => 'windows',
'ignored_at' => now(),
]);
$backupSet = BackupSet::factory()->for($tenant)->create([
'item_count' => 2,
@ -39,6 +47,18 @@
])
->create();
BackupItem::factory()
->for($tenant)
->for($backupSet)
->state([
'policy_id' => $ignoredPolicy->id,
'policy_identifier' => $ignoredPolicy->external_id,
'policy_type' => $ignoredPolicy->policy_type,
'platform' => $ignoredPolicy->platform,
'payload' => ['id' => $ignoredPolicy->external_id],
])
->create();
BackupItem::factory()
->for($tenant)
->for($backupSet)
@ -65,6 +85,7 @@
'backup_set_id' => $backupSet->id,
])
->assertSee('Policy Display')
->assertDontSee('Ignored Policy')
->assertSee('Scope Tag Alpha')
->assertSee('Settings Catalog Policy')
->assertSee('Scope Tag')

View File

@ -103,3 +103,89 @@ public function request(string $method, string $path, array $options = []): Grap
$policyPreview = collect($preview)->first(fn (array $item) => isset($item['action']));
expect($policyPreview['action'])->toBe('update');
});
test('restore preview warns about missing compliance notification templates', function () {
app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface
{
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
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, []);
}
});
$tenant = Tenant::create([
'tenant_id' => 'tenant-2',
'name' => 'Tenant Two',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-2',
'policy_type' => 'deviceCompliancePolicy',
'display_name' => 'Compliance Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'Backup',
'status' => 'completed',
'item_count' => 1,
]);
BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'scheduledActionsForRule' => [
[
'ruleName' => 'Password',
'scheduledActionConfigurations' => [
[
'actionType' => 'notification',
'notificationTemplateId' => 'template-1',
],
],
],
],
],
]);
$service = app(RestoreService::class);
$preview = $service->preview($tenant, $backupSet);
$policyPreview = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceCompliancePolicy');
expect($policyPreview['compliance_action_warning'] ?? null)->not->toBeNull();
expect(($policyPreview['compliance_action_summary']['missing'] ?? 0))->toBe(1);
});

View File

@ -536,7 +536,9 @@ public function request(string $method, string $path, array $options = []): Grap
expect($client->requestCalls[1]['path'])->toBe('deviceManagement/configurationPolicies');
expect($client->requestCalls[1]['payload'])->toHaveKey('settings');
expect($client->requestCalls[1]['payload'])->toHaveKey('name');
expect($client->requestCalls[1]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon');
expect($client->requestCalls[2]['path'])->toBe('deviceManagement/configurationPolicies');
expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings');
expect($client->requestCalls[2]['payload'])->toHaveKey('name');
expect($client->requestCalls[2]['payload']['name'])->toBe('Restored_Settings Catalog Epsilon');
});

View File

@ -92,3 +92,23 @@
$response->assertOk();
$response->assertSee('Assignments were not captured for this version');
});
it('shows empty assignments message when assignments were fetched', function () {
$version = PolicyVersion::factory()->create([
'tenant_id' => $this->tenant->id,
'policy_id' => $this->policy->id,
'version_number' => 1,
'assignments' => null,
'metadata' => [
'assignments_fetched' => true,
'assignments_count' => 0,
],
]);
$this->actingAs($this->user);
$response = $this->get("/admin/policy-versions/{$version->id}");
$response->assertOk();
$response->assertSee('No assignments found for this version');
});

View File

@ -98,6 +98,70 @@
expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
});
it('hydrates assignment filter names when filter data is stored at root', function () {
$this->mock(PolicySnapshotService::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
'payload' => [
'id' => 'test-policy-id',
'name' => 'Test Policy',
'settings' => [],
],
]);
});
$this->mock(AssignmentFetcher::class, function ($mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
[
'id' => 'assignment-1',
'intent' => 'apply',
'deviceAndAppManagementAssignmentFilterId' => 'filter-123',
'deviceAndAppManagementAssignmentFilterType' => 'include',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
],
],
]);
});
$this->mock(GroupResolver::class, function ($mock) {
$mock->shouldReceive('resolveGroupIds')
->once()
->andReturn([
'group-123' => [
'id' => 'group-123',
'displayName' => 'Test Group',
'orphaned' => false,
],
]);
});
$this->mock(AssignmentFilterResolver::class, function ($mock) {
$mock->shouldReceive('resolve')
->once()
->andReturn([
['id' => 'filter-123', 'displayName' => 'Targeted Devices'],
]);
});
$versionService = app(VersionService::class);
$version = $versionService->captureFromGraph(
$this->tenant,
$this->policy,
'test@example.com'
);
expect($version->assignments)->not->toBeNull()
->and($version->assignments)->toHaveCount(1)
->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123')
->and($version->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include')
->and($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
});
it('captures policy version without assignments when none exist', function () {
// Mock dependencies
$this->mock(PolicySnapshotService::class, function ($mock) {
@ -127,7 +191,9 @@
expect($version)->not->toBeNull()
->and($version->assignments)->toBeNull()
->and($version->assignments_hash)->toBeNull();
->and($version->assignments_hash)->toBeNull()
->and($version->metadata['assignments_fetched'])->toBeTrue()
->and($version->metadata['assignments_count'])->toBe(0);
});
it('handles assignment fetch failure gracefully', function () {

View File

@ -0,0 +1,93 @@
<?php
use App\Models\BackupItem;
use App\Models\Tenant;
use App\Services\AssignmentBackupService;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('enriches assignment filter names when filter data is stored at root', function () {
$tenant = Tenant::factory()->create([
'tenant_id' => 'tenant-123',
'external_id' => 'tenant-123',
]);
$backupItem = BackupItem::factory()->create([
'tenant_id' => $tenant->id,
'metadata' => [],
'assignments' => null,
]);
$policyPayload = [
'roleScopeTagIds' => ['0'],
];
$this->mock(AssignmentFetcher::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch')
->once()
->andReturn([
[
'id' => 'assignment-1',
'intent' => 'apply',
'deviceAndAppManagementAssignmentFilterId' => 'filter-123',
'deviceAndAppManagementAssignmentFilterType' => 'include',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-123',
],
],
]);
});
$this->mock(GroupResolver::class, function (MockInterface $mock) {
$mock->shouldReceive('resolveGroupIds')
->once()
->andReturn([
'group-123' => [
'id' => 'group-123',
'displayName' => 'Test Group',
'orphaned' => false,
],
]);
});
$this->mock(AssignmentFilterResolver::class, function (MockInterface $mock) use ($tenant) {
$mock->shouldReceive('resolve')
->once()
->with(['filter-123'], $tenant)
->andReturn([
['id' => 'filter-123', 'displayName' => 'Targeted Devices'],
]);
});
$this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant) {
$mock->shouldReceive('resolve')
->once()
->with(['0'], $tenant)
->andReturn([
['id' => '0', 'displayName' => 'Default'],
]);
});
$service = app(AssignmentBackupService::class);
$updated = $service->enrichWithAssignments(
backupItem: $backupItem,
tenant: $tenant,
policyType: 'settingsCatalogPolicy',
policyId: 'policy-123',
policyPayload: $policyPayload,
includeAssignments: true
);
expect($updated->assignments)->toHaveCount(1)
->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-123')
->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include')
->and($updated->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices');
});

View File

@ -1,6 +1,7 @@
<?php
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphException;
use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient;
@ -11,12 +12,13 @@
beforeEach(function () {
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->fetcher = new AssignmentFetcher($this->graphClient);
$this->fetcher = new AssignmentFetcher($this->graphClient, app(GraphContractRegistry::class));
});
test('primary endpoint success', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']],
@ -35,7 +37,7 @@
])
->andReturn($response);
$result = $this->fetcher->fetch($tenantId, $policyId);
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe($assignments);
});
@ -43,6 +45,7 @@
test('fallback on empty response', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$assignments = [
['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']],
];
@ -70,7 +73,7 @@
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', '/deviceManagement/configurationPolicies', [
->with('GET', 'deviceManagement/configurationPolicies', [
'tenant' => $tenantId,
'query' => [
'$expand' => 'assignments',
@ -79,7 +82,7 @@
])
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId);
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe($assignments);
});
@ -87,13 +90,14 @@
test('fail soft on error', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$this->graphClient
->shouldReceive('request')
->once()
->twice()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
$result = $this->fetcher->fetch($tenantId, $policyId);
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]);
});
@ -101,6 +105,7 @@
test('returns empty array when both endpoints return empty', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
// Primary returns empty
$primaryResponse = new GraphResponse(
@ -123,10 +128,10 @@
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', '/deviceManagement/configurationPolicies', Mockery::any())
->with('GET', 'deviceManagement/configurationPolicies', Mockery::any())
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId);
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]);
});
@ -134,6 +139,7 @@
test('fallback handles missing assignments key', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
// Primary returns empty
$primaryResponse = new GraphResponse(
@ -157,7 +163,34 @@
->once()
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId);
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
expect($result)->toBe([]);
});
test('throws when both endpoints fail with throwOnFailure enabled', function () {
$tenantId = 'tenant-123';
$policyId = 'policy-456';
$policyType = 'settingsCatalogPolicy';
$failureResponse = new GraphResponse(
success: false,
data: [],
status: 403,
errors: [['message' => 'Forbidden']]
);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any())
->andReturn($failureResponse);
$this->graphClient
->shouldReceive('request')
->once()
->with('GET', 'deviceManagement/configurationPolicies', Mockery::any())
->andReturn($failureResponse);
$this->fetcher->fetch($policyType, $tenantId, $policyId, [], true);
})->throws(GraphException::class);

View File

@ -0,0 +1,202 @@
<?php
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\AuditLogger;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
config()->set('graph_contracts.types.deviceManagementScript', [
'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign',
'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceManagementScriptAssignments',
]);
config()->set('graph_contracts.types.settingsCatalogPolicy', [
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
'assignments_create_method' => 'POST',
]);
$this->graphClient = Mockery::mock(GraphClientInterface::class);
$this->auditLogger = Mockery::mock(AuditLogger::class);
$this->filterResolver = Mockery::mock(AssignmentFilterResolver::class);
$this->filterResolver->shouldReceive('resolve')->andReturn([])->byDefault();
$this->service = new AssignmentRestoreService(
$this->graphClient,
app(GraphContractRegistry::class),
app(GraphLogger::class),
$this->auditLogger,
$this->filterResolver,
);
});
it('uses the contract assignment payload key for assign actions', 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',
],
],
];
$expectedAssignments = [
[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-1',
],
],
];
$this->graphClient
->shouldReceive('request')
->once()
->with('POST', "/deviceManagement/deviceManagementScripts/{$policyId}/assign", Mockery::on(
fn (array $options) => ($options['json']['deviceManagementScriptAssignments'] ?? null) === $expectedAssignments
))
->andReturn(new GraphResponse(success: true, data: []));
$this->auditLogger
->shouldReceive('log')
->once()
->andReturn(new AuditLog);
$result = $this->service->restore(
$tenant,
'deviceManagementScript',
$policyId,
$assignments,
[]
);
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',
'app_client_id' => null,
'app_client_secret' => null,
]);
$policyId = 'policy-789';
$assignments = [
[
'id' => 'assignment-1',
'deviceAndAppManagementAssignmentFilterId' => 'filter-source',
'deviceAndAppManagementAssignmentFilterType' => 'include',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-1',
],
],
];
$expectedAssignments = [
[
'deviceAndAppManagementAssignmentFilterId' => 'filter-target',
'deviceAndAppManagementAssignmentFilterType' => 'include',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-1',
],
],
];
$this->graphClient
->shouldReceive('request')
->once()
->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on(
fn (array $options) => ($options['json']['assignments'] ?? null) === $expectedAssignments
))
->andReturn(new GraphResponse(success: true, data: []));
$this->auditLogger
->shouldReceive('log')
->once()
->andReturn(new AuditLog);
$result = $this->service->restore(
$tenant,
'settingsCatalogPolicy',
$policyId,
$assignments,
[],
[
'assignmentFilter' => [
'filter-source' => 'filter-target',
],
]
);
expect($result['summary']['success'])->toBe(1);
expect($result['summary']['failed'])->toBe(0);
expect($result['summary']['skipped'])->toBe(0);
});
it('keeps assignment filters when mapping is missing but filter exists in target', function () {
$tenant = Tenant::factory()->make([
'tenant_id' => 'tenant-123',
'app_client_id' => null,
'app_client_secret' => null,
]);
$policyId = 'policy-999';
$assignments = [
[
'id' => 'assignment-1',
'deviceAndAppManagementAssignmentFilterId' => 'filter-1',
'deviceAndAppManagementAssignmentFilterType' => 'include',
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => 'group-1',
],
],
];
$this->filterResolver
->shouldReceive('resolve')
->once()
->with(['filter-1'], $tenant)
->andReturn([['id' => 'filter-1', 'displayName' => 'Test']]);
$this->graphClient
->shouldReceive('request')
->once()
->with('POST', "/deviceManagement/configurationPolicies/{$policyId}/assign", Mockery::on(
fn (array $options) => ($options['json']['assignments'][0]['deviceAndAppManagementAssignmentFilterId'] ?? null) === 'filter-1'
))
->andReturn(new GraphResponse(success: true, data: []));
$this->auditLogger
->shouldReceive('log')
->once()
->andReturn(new AuditLog);
$result = $this->service->restore(
$tenant,
'settingsCatalogPolicy',
$policyId,
$assignments,
[],
[]
);
expect($result['summary']['success'])->toBe(1);
expect($result['summary']['failed'])->toBe(0);
expect($result['summary']['skipped'])->toBe(0);
});

View File

@ -0,0 +1,36 @@
<?php
use App\Services\Intune\CompliancePolicyNormalizer;
uses(Tests\TestCase::class);
it('groups compliance policy fields into structured blocks', function () {
$normalizer = app(CompliancePolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.windows10CompliancePolicy',
'passwordRequired' => true,
'passwordMinimumLength' => 8,
'defenderEnabled' => true,
'bitLockerEnabled' => false,
'osMinimumVersion' => '10.0.19045',
'activeFirewallRequired' => true,
'customSetting' => 'Custom value',
];
$normalized = $normalizer->normalize($snapshot, 'deviceCompliancePolicy', 'windows');
$settings = collect($normalized['settings']);
$passwordBlock = $settings->firstWhere('title', 'Password & Access');
expect($passwordBlock)->not->toBeNull();
expect(collect($passwordBlock['rows'])->pluck('label')->all())
->toContain('Password required', 'Password minimum length');
$additionalBlock = $settings->firstWhere('title', 'Additional Settings');
expect($additionalBlock)->not->toBeNull();
expect(collect($additionalBlock['rows'])->pluck('label')->all())
->toContain('Custom Setting');
expect($settings->pluck('title')->all())->not->toContain('General');
});

View File

@ -0,0 +1,39 @@
<?php
use App\Services\Intune\DeviceConfigurationPolicyNormalizer;
uses(Tests\TestCase::class);
it('builds a configuration block for device configuration policies', function () {
$normalizer = app(DeviceConfigurationPolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.windows10CustomConfiguration',
'displayName' => 'Device Config Policy',
'description' => 'Test policy',
'omaSettings' => [
[
'displayName' => 'Setting A',
'omaUri' => './Vendor/MSFT/SettingA',
'value' => 'Enabled',
],
],
'customSetting' => 'Custom value',
'nestedSetting' => ['value' => 'Nested'],
];
$normalized = $normalizer->normalize($snapshot, 'deviceConfiguration', 'windows');
$settings = collect($normalized['settings']);
$omaBlock = $settings->firstWhere('title', 'OMA-URI settings');
expect($omaBlock)->not->toBeNull();
$configurationBlock = $settings->firstWhere('title', 'Configuration');
expect($configurationBlock)->not->toBeNull();
$labels = collect($configurationBlock['entries'])->pluck('key')->all();
expect($labels)->toContain('Custom Setting', 'Nested Setting');
expect($labels)->not->toContain('Display Name');
expect($settings->pluck('title')->all())->not->toContain('General');
});

View File

@ -49,3 +49,47 @@
// Null values should be preserved (Graph might need them)
expect(array_key_exists('settingValueTemplateReference', $sanitized[0]['settingInstance']['choiceSettingValue']))->toBeTrue();
});
it('exposes autopilot assignments paths', function () {
$contract = $this->registry->get('windowsAutopilotDeploymentProfile');
expect($contract)->not->toBeEmpty();
expect($contract['assignments_list_path'] ?? null)
->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments');
expect($contract['assignments_create_path'] ?? null)
->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments');
expect($contract['assignments_delete_path'] ?? null)
->toBe('/deviceManagement/windowsAutopilotDeploymentProfiles/{id}/assignments/{assignmentId}');
expect($this->registry->matchesTypeFamily(
'windowsAutopilotDeploymentProfile',
'#microsoft.graph.azureADWindowsAutopilotDeploymentProfile'
))->toBeTrue();
expect($this->registry->matchesTypeFamily(
'windowsAutopilotDeploymentProfile',
'#microsoft.graph.activeDirectoryWindowsAutopilotDeploymentProfile'
))->toBeTrue();
});
it('sanitizes autopilot update payload by stripping odata and assignments', function () {
$payload = [
'@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile',
'id' => 'profile-1',
'displayName' => 'Autopilot Profile',
'assignments' => [['id' => 'assignment-1']],
'managementServiceAppId' => 'service-app',
'outOfBoxExperienceSetting' => ['deviceUsageType' => 'shared'],
'hardwareHashExtractionEnabled' => true,
'locale' => 'de-DE',
];
$sanitized = $this->registry->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $payload);
expect($sanitized)->toHaveKey('displayName');
expect($sanitized)->toHaveKey('@odata.type');
expect($sanitized)->not->toHaveKey('id');
expect($sanitized)->not->toHaveKey('assignments');
expect($sanitized)->not->toHaveKey('managementServiceAppId');
expect($sanitized)->not->toHaveKey('outOfBoxExperienceSetting');
expect($sanitized)->not->toHaveKey('hardwareHashExtractionEnabled');
expect($sanitized)->not->toHaveKey('locale');
});

View File

@ -0,0 +1,44 @@
<?php
use App\Services\Intune\DefaultPolicyNormalizer;
use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\PolicyTypeNormalizer;
uses(Tests\TestCase::class);
it('routes to the first matching policy type normalizer', function () {
$defaultNormalizer = app(DefaultPolicyNormalizer::class);
$customNormalizer = new class implements PolicyTypeNormalizer
{
public function supports(string $policyType): bool
{
return $policyType === 'deviceCompliancePolicy';
}
public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array
{
return [
'status' => 'custom',
'settings' => [],
'warnings' => [],
];
}
public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
{
return ['custom' => true];
}
};
$normalizer = new PolicyNormalizer($defaultNormalizer, [$customNormalizer]);
$custom = $normalizer->normalize(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows');
expect($custom['status'])->toBe('custom');
$customDiff = $normalizer->flattenForDiff(['id' => 'policy-1'], 'deviceCompliancePolicy', 'windows');
expect($customDiff)->toBe(['custom' => true]);
$fallback = $normalizer->normalize(['id' => 'policy-1'], 'unknownPolicy', 'windows');
expect($fallback['status'])->not->toBe('custom');
});

View File

@ -0,0 +1,33 @@
<?php
use App\Services\Intune\SettingsCatalogPolicyNormalizer;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(Tests\TestCase::class, RefreshDatabase::class);
it('builds a settings table for settings catalog policies', function () {
$normalizer = app(SettingsCatalogPolicyNormalizer::class);
$snapshot = [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy',
'settings' => [
[
'id' => 's1',
'settingInstance' => [
'@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance',
'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring',
'simpleSettingValue' => [
'value' => 1,
],
],
],
],
];
$normalized = $normalizer->normalize($snapshot, 'settingsCatalogPolicy', 'windows');
$rows = $normalized['settings_table']['rows'] ?? [];
expect($rows)->toHaveCount(1);
expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring');
});