feat(007): device config & compliance snapshot/restore improvements #9

Merged
ahmido merged 18 commits from feat/007-device-config-compliance into dev 2025-12-29 12:46:20 +00:00
21 changed files with 987 additions and 111 deletions
Showing only changes of commit 8aa9fd4d0f - Show all commits

View File

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

View File

@ -543,6 +543,11 @@ private static function restoreItemOptionData(?int $backupSetId): array
$items = BackupItem::query() $items = BackupItem::query()
->where('backup_set_id', $backupSetId) ->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) ->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') ->with('policy:id,display_name')
->get() ->get()
->sortBy(function (BackupItem $item) { ->sortBy(function (BackupItem $item) {

View File

@ -93,12 +93,7 @@ public function enrichWithAssignments(
->contains(fn (array $group) => $group['orphaned'] ?? false); ->contains(fn (array $group) => $group['orphaned'] ?? false);
} }
$filterIds = collect($assignments) $filterIds = $this->extractAssignmentFilterIds($assignments);
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters) $filterNames = collect($filters)
@ -183,14 +178,50 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
} }
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; $filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
if ($filterId && isset($filterNames[$filterId])) { ?? ($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]; $target['assignment_filter_name'] = $filterNames[$filterId];
} }
}
$assignment['target'] = $target; $assignment['target'] = $target;
return $assignment; return $assignment;
}, $assignments); }, $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\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphLogger; use App\Services\Graph\GraphLogger;
@ -19,6 +20,7 @@ public function __construct(
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly GraphLogger $graphLogger, private readonly GraphLogger $graphLogger,
private readonly AuditLogger $auditLogger, private readonly AuditLogger $auditLogger,
private readonly AssignmentFilterResolver $assignmentFilterResolver,
) {} ) {}
/** /**
@ -56,6 +58,11 @@ public function restore(
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign'); $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); $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
$deletePathTemplate = $contract['assignments_delete_path'] ?? null; $deletePathTemplate = $contract['assignments_delete_path'] ?? null;
@ -84,13 +91,39 @@ public function restore(
$assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? []; $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) { foreach ($assignments as $assignment) {
if (! is_array($assignment)) { if (! is_array($assignment)) {
continue; continue;
} }
$target = $assignment['target'] ?? []; $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 ($filterId !== null) {
if ($assignmentFilterMapping === []) { if ($assignmentFilterMapping === []) {
@ -142,9 +175,13 @@ public function restore(
continue; continue;
} }
if ($filterLocation === 'root') {
$assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
} else {
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
$assignment['target'] = $target; $assignment['target'] = $target;
} }
}
$groupId = $assignment['target']['groupId'] ?? null; $groupId = $assignment['target']['groupId'] ?? null;
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null; $mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
@ -196,7 +233,7 @@ public function restore(
]); ]);
$assignResponse = $this->graphClient->request($createMethod, $createPath, [ $assignResponse = $this->graphClient->request($createMethod, $createPath, [
'json' => ['assignments' => $preparedAssignments], 'json' => [$assignmentsPayloadKey => $preparedAssignments],
] + $graphOptions); ] + $graphOptions);
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [ $this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
@ -413,6 +450,34 @@ private function resolvePath(?string $template, string $policyId, ?string $assig
return $path; 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 private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
{ {
if (! $mappedGroupId) { if (! $mappedGroupId) {

View File

@ -19,16 +19,38 @@ public function __construct(
* *
* @return array Returns assignment array or empty array on failure * @return array Returns assignment array or empty array on failure
*/ */
public function fetch(string $policyType, string $tenantId, string $policyId, array $options = []): array public function fetch(
{ string $policyType,
try { string $tenantId,
string $policyId,
array $options = [],
bool $throwOnFailure = false
): array {
$contract = $this->contracts->get($policyType); $contract = $this->contracts->get($policyType);
$listPathTemplate = $contract['assignments_list_path'] ?? null; $listPathTemplate = $contract['assignments_list_path'] ?? null;
$resource = $contract['resource'] ?? null; $resource = $contract['resource'] ?? null;
$requestOptions = array_merge($options, ['tenant' => $tenantId]); $requestOptions = array_merge($options, ['tenant' => $tenantId]);
$context = [
'tenant_id' => $tenantId,
'policy_type' => $policyType,
'policy_id' => $policyId,
];
$primaryException = null;
$assignments = [];
// Try primary endpoint // Try primary endpoint
$assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions); try {
$assignments = $this->fetchPrimary(
$listPathTemplate,
$policyId,
$requestOptions,
$context,
$throwOnFailure
);
} catch (GraphException $e) {
$primaryException = $e;
}
if (! empty($assignments)) { if (! empty($assignments)) {
Log::debug('Fetched assignments via primary endpoint', [ Log::debug('Fetched assignments via primary endpoint', [
@ -55,10 +77,34 @@ public function fetch(string $policyType, string $tenantId, string $policyId, ar
'policy_id' => $policyId, '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 []; return [];
} }
$assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions); $fallbackException = null;
try {
$assignments = $this->fetchWithExpand(
$resource,
$policyId,
$requestOptions,
$context,
$throwOnFailure
);
} catch (GraphException $e) {
$fallbackException = $e;
}
if (! empty($assignments)) { if (! empty($assignments)) {
Log::debug('Fetched assignments via fallback endpoint', [ Log::debug('Fetched assignments via fallback endpoint', [
@ -78,25 +124,33 @@ public function fetch(string $policyType, string $tenantId, string $policyId, ar
'policy_id' => $policyId, 'policy_id' => $policyId,
]); ]);
return []; if ($throwOnFailure && ($fallbackException || $primaryException)) {
} catch (GraphException $e) { $exception = $fallbackException ?? $primaryException;
Log::warning('Failed to fetch assignments', [ Log::warning('Failed to fetch assignments', [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'policy_type' => $policyType, 'policy_type' => $policyType,
'policy_id' => $policyId, 'policy_id' => $policyId,
'error' => $e->getMessage(), 'error' => $exception->getMessage(),
'context' => $e->context, 'context' => $exception->context,
]); ]);
return []; throw $exception;
} }
return [];
} }
/** /**
* Fetch assignments using primary endpoint. * Fetch assignments using primary endpoint.
*/ */
private function fetchPrimary(?string $listPathTemplate, string $policyId, array $options): array private function fetchPrimary(
{ ?string $listPathTemplate,
string $policyId,
array $options,
array $context,
bool $throwOnFailure
): array {
if (! is_string($listPathTemplate) || $listPathTemplate === '') { if (! is_string($listPathTemplate) || $listPathTemplate === '') {
return []; return [];
} }
@ -109,14 +163,33 @@ private function fetchPrimary(?string $listPathTemplate, string $policyId, array
$response = $this->graphClient->request('GET', $path, $options); $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'] ?? []; return $response->data['value'] ?? [];
} }
/** /**
* Fetch assignments using $expand fallback. * Fetch assignments using $expand fallback.
*/ */
private function fetchWithExpand(string $resource, string $policyId, array $options): array private function fetchWithExpand(
{ string $resource,
string $policyId,
array $options,
array $context,
bool $throwOnFailure
): array {
$path = $resource; $path = $resource;
$params = [ $params = [
'$expand' => 'assignments', '$expand' => 'assignments',
@ -127,6 +200,20 @@ private function fetchWithExpand(string $resource, string $policyId, array $opti
'query' => $params, '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'] ?? []; $policies = $response->data['value'] ?? [];
if (empty($policies)) { if (empty($policies)) {
@ -144,4 +231,32 @@ private function resolvePath(string $template, string $policyId): ?string
return str_replace('{id}', urlencode($policyId), $template); 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

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

View File

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

View File

@ -276,7 +276,7 @@ public function execute(
settings: $settings, settings: $settings,
graphOptions: $graphOptions, graphOptions: $graphOptions,
context: $context, context: $context,
fallbackName: $item->policy_identifier, fallbackName: $item->resolvedDisplayName(),
); );
if ($createOutcome['success']) { if ($createOutcome['success']) {
@ -385,6 +385,7 @@ public function execute(
$assignmentOutcomes = null; $assignmentOutcomes = null;
$assignmentSummary = null; $assignmentSummary = null;
$restoredAssignments = null;
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
@ -410,6 +411,19 @@ public function execute(
} }
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') { if (is_array($complianceActionSummary) && ($complianceActionSummary['skipped'] ?? 0) > 0 && $itemStatus === 'applied') {
$itemStatus = 'partial'; $itemStatus = 'partial';
$resultReason = 'Compliance notification actions skipped'; $resultReason = 'Compliance notification actions skipped';
@ -486,7 +500,8 @@ public function execute(
'source' => 'restore', 'source' => 'restore',
'restore_run_id' => $restoreRun->id, 'restore_run_id' => $restoreRun->id,
'backup_item_id' => $item->id, 'backup_item_id' => $item->id,
] ],
assignments: $restoredAssignments,
); );
} }
} }
@ -1365,6 +1380,11 @@ private function createAutopilotDeploymentProfileIfMissing(
$resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile') $resource = $this->contracts->resourcePath('windowsAutopilotDeploymentProfile')
?? 'deviceManagement/windowsAutopilotDeploymentProfiles'; ?? 'deviceManagement/windowsAutopilotDeploymentProfiles';
$payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload); $payload = $this->contracts->sanitizeUpdatePayload('windowsAutopilotDeploymentProfile', $originalPayload);
$payload['displayName'] = $this->prefixRestoredName(
$this->resolvePayloadString($payload, ['displayName', 'name']),
$policyId
);
unset($payload['name']);
if ($payload === []) { if ($payload === []) {
return [ return [
@ -1463,7 +1483,7 @@ private function buildSettingsCatalogCreatePayload(
$payload = []; $payload = [];
$name = $this->resolvePayloadString($originalPayload, ['name', 'displayName']); $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']); $description = $this->resolvePayloadString($originalPayload, ['description', 'Description']);
if ($description !== null) { if ($description !== null) {
@ -1505,6 +1525,24 @@ private function buildSettingsCatalogCreatePayload(
return $payload; 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<string, mixed> $payload
* @param array<int, string> $keys * @param array<int, string> $keys

View File

@ -91,7 +91,15 @@ public function captureFromGraph(
if ($includeAssignments) { if ($includeAssignments) {
try { try {
$rawAssignments = $this->assignmentFetcher->fetch($policy->policy_type, $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)) { if (! empty($rawAssignments)) {
$resolvedGroups = []; $resolvedGroups = [];
@ -110,14 +118,8 @@ public function captureFromGraph(
$assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups)
->contains(fn (array $group) => $group['orphaned'] ?? false); ->contains(fn (array $group) => $group['orphaned'] ?? false);
$assignmentMetadata['assignments_count'] = count($rawAssignments);
$filterIds = collect($rawAssignments) $filterIds = $this->extractAssignmentFilterIds($rawAssignments);
->pluck('target.deviceAndAppManagementAssignmentFilterId')
->filter()
->unique()
->values()
->all();
$filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant);
$filterNames = collect($filters) $filterNames = collect($filters)
@ -170,10 +172,22 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
$target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false;
} }
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; $filterId = $assignment['deviceAndAppManagementAssignmentFilterId']
if ($filterId && isset($filterNames[$filterId])) { ?? ($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]; $target['assignment_filter_name'] = $filterNames[$filterId];
} }
}
$assignment['target'] = $target; $assignment['target'] = $target;
@ -181,6 +195,30 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
}, $assignments); }, $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 * @param array<int, string> $scopeTagIds
* @return array{ids:array<int, string>,names:array<int, string>} * @return array{ids:array<int, string>,names:array<int, string>}

View File

@ -196,6 +196,7 @@
'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceManagementScripts/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceManagementScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}', 'assignments_delete_path' => '/deviceManagement/deviceManagementScripts/{id}/assignments/{assignmentId}',
@ -215,6 +216,7 @@
'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceShellScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceShellScripts/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceManagementScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}', 'assignments_delete_path' => '/deviceManagement/deviceShellScripts/{id}/assignments/{assignmentId}',
@ -234,6 +236,7 @@
'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments', 'assignments_list_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments',
'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign', 'assignments_create_path' => '/deviceManagement/deviceHealthScripts/{id}/assign',
'assignments_create_method' => 'POST', 'assignments_create_method' => 'POST',
'assignments_payload_key' => 'deviceHealthScriptAssignments',
'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',
'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}',
@ -251,6 +254,10 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', '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' => [ 'windowsAutopilotDeploymentProfile' => [
'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles',
@ -290,6 +297,10 @@
'update_method' => 'PATCH', 'update_method' => 'PATCH',
'id_field' => 'id', 'id_field' => 'id',
'hydration' => 'properties', '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' => [ 'endpointSecurityIntent' => [
'resource' => 'deviceManagement/intents', 'resource' => 'deviceManagement/intents',

View File

@ -118,9 +118,29 @@
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white"> <h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments Assignments
</h3> </h3>
@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"> <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version. Assignments were not captured for this version.
</p> </p>
@endif
@php @php
$hasBackupItem = $version->policy->backupItems() $hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments') ->whereNotNull('assignments')

View File

@ -42,9 +42,9 @@ ## Phase 3: Restore Logic and Mapping
**Purpose**: Restore new policy types safely using assignment and foundation mappings. **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. - [ ] T009 Update `app/Services/Intune/RestoreService.php` to restore the new policy types using Graph contracts.
- [ ] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types. - [x] T010 Extend `app/Services/AssignmentRestoreService.php` for assignment endpoints of the new types.
- [ ] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`. - [x] T011 Ensure compliance notification templates are restored and referenced via mapping in `app/Services/Intune/RestoreService.php`.
- [ ] T012 Add audit coverage for compliance action mapping outcomes in `app/Services/Intune/AuditLogger.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. **Checkpoint**: Restore applies policies and assignments or skips with clear reasons.

View File

@ -19,7 +19,7 @@
// Mock PolicySnapshotService // Mock PolicySnapshotService
$this->mock(PolicySnapshotService::class, function (MockInterface $mock) { $this->mock(PolicySnapshotService::class, function (MockInterface $mock) {
$mock->shouldReceive('fetch') $mock->shouldReceive('fetch')
->twice() // Called once for each policy ->once() // Called once for the active policy
->andReturnUsing(function ($tenant, $policy) { ->andReturnUsing(function ($tenant, $policy) {
return [ return [
'payload' => [ 'payload' => [
@ -96,6 +96,7 @@ public function request(string $method, string $path, array $options = []): Grap
'display_name' => 'Policy B', 'display_name' => 'Policy B',
'platform' => 'windows', 'platform' => 'windows',
'last_synced_at' => now(), 'last_synced_at' => now(),
'ignored_at' => now(),
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
@ -109,15 +110,15 @@ public function request(string $method, string $path, array $options = []): Grap
'ownerRecord' => $backupSet, 'ownerRecord' => $backupSet,
'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class,
])->callTableAction('addPolicies', data: [ ])->callTableAction('addPolicies', data: [
'policy_ids' => [$policyA->id, $policyB->id], 'policy_ids' => [$policyA->id],
'include_assignments' => false, 'include_assignments' => false,
'include_scope_tags' => true, 'include_scope_tags' => true,
]); ]);
$backupSet->refresh(); $backupSet->refresh();
expect($backupSet->item_count)->toBe(2); expect($backupSet->item_count)->toBe(1);
expect($backupSet->items)->toHaveCount(2); expect($backupSet->items)->toHaveCount(1);
expect($backupSet->items->first()->payload['id'])->toBe('policy-1'); expect($backupSet->items->first()->payload['id'])->toBe('policy-1');
$firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id); $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, '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

@ -292,6 +292,8 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
public int $createCalls = 0; public int $createCalls = 0;
public array $createPayloads = [];
public function listPolicies(string $policyType, array $options = []): GraphResponse public function listPolicies(string $policyType, array $options = []): GraphResponse
{ {
return new GraphResponse(true, []); return new GraphResponse(true, []);
@ -326,6 +328,7 @@ public function request(string $method, string $path, array $options = []): Grap
{ {
if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) { if ($method === 'POST' && str_contains($path, 'windowsAutopilotDeploymentProfiles')) {
$this->createCalls++; $this->createCalls++;
$this->createPayloads[] = $options['json'] ?? [];
return new GraphResponse(true, ['id' => 'autopilot-created']); return new GraphResponse(true, ['id' => 'autopilot-created']);
} }
@ -384,6 +387,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
expect($graphClient->applyCalls)->toBe(1); expect($graphClient->applyCalls)->toBe(1);
expect($graphClient->getCalls)->toBe(1); expect($graphClient->getCalls)->toBe(1);
expect($graphClient->createCalls)->toBe(1); expect($graphClient->createCalls)->toBe(1);
expect($graphClient->createPayloads[0]['displayName'] ?? null)->toBe('Restored_Autopilot Profile');
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied'); expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['created_policy_id'])->toBe('autopilot-created'); expect($run->results[0]['created_policy_id'])->toBe('autopilot-created');

View File

@ -22,6 +22,14 @@
'display_name' => 'Policy Display', 'display_name' => 'Policy Display',
'platform' => 'windows', '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([ $backupSet = BackupSet::factory()->for($tenant)->create([
'item_count' => 2, 'item_count' => 2,
@ -39,6 +47,18 @@
]) ])
->create(); ->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() BackupItem::factory()
->for($tenant) ->for($tenant)
->for($backupSet) ->for($backupSet)
@ -65,6 +85,7 @@
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
]) ])
->assertSee('Policy Display') ->assertSee('Policy Display')
->assertDontSee('Ignored Policy')
->assertSee('Scope Tag Alpha') ->assertSee('Scope Tag Alpha')
->assertSee('Settings Catalog Policy') ->assertSee('Settings Catalog Policy')
->assertSee('Scope Tag') ->assertSee('Scope Tag')

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]['path'])->toBe('deviceManagement/configurationPolicies');
expect($client->requestCalls[1]['payload'])->toHaveKey('settings'); expect($client->requestCalls[1]['payload'])->toHaveKey('settings');
expect($client->requestCalls[1]['payload'])->toHaveKey('name'); 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]['path'])->toBe('deviceManagement/configurationPolicies');
expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings'); expect($client->requestCalls[2]['payload'])->not->toHaveKey('settings');
expect($client->requestCalls[2]['payload'])->toHaveKey('name'); 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->assertOk();
$response->assertSee('Assignments were not captured for this version'); $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'); 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 () { it('captures policy version without assignments when none exist', function () {
// Mock dependencies // Mock dependencies
$this->mock(PolicySnapshotService::class, function ($mock) { $this->mock(PolicySnapshotService::class, function ($mock) {
@ -127,7 +191,9 @@
expect($version)->not->toBeNull() expect($version)->not->toBeNull()
->and($version->assignments)->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 () { 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

@ -94,7 +94,7 @@
$this->graphClient $this->graphClient
->shouldReceive('request') ->shouldReceive('request')
->once() ->twice()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123']));
$result = $this->fetcher->fetch($policyType, $tenantId, $policyId); $result = $this->fetcher->fetch($policyType, $tenantId, $policyId);
@ -167,3 +167,30 @@
expect($result)->toBe([]); 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);
});