feat(007): device config & compliance snapshot/restore improvements #9
@ -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')
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,9 +178,21 @@ 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);
|
||||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
$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;
|
$assignment['target'] = $target;
|
||||||
@ -193,4 +200,28 @@ private function enrichAssignments(array $assignments, array $groups, array $fil
|
|||||||
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,8 +175,12 @@ public function restore(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
if ($filterLocation === 'root') {
|
||||||
$assignment['target'] = $target;
|
$assignment['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
||||||
|
} else {
|
||||||
|
$target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId;
|
||||||
|
$assignment['target'] = $target;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupId = $assignment['target']['groupId'] ?? null;
|
$groupId = $assignment['target']['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) {
|
||||||
|
|||||||
@ -19,84 +19,138 @@ 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,
|
||||||
|
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 {
|
try {
|
||||||
$contract = $this->contracts->get($policyType);
|
$assignments = $this->fetchPrimary(
|
||||||
$listPathTemplate = $contract['assignments_list_path'] ?? null;
|
$listPathTemplate,
|
||||||
$resource = $contract['resource'] ?? null;
|
$policyId,
|
||||||
$requestOptions = array_merge($options, ['tenant' => $tenantId]);
|
$requestOptions,
|
||||||
|
$context,
|
||||||
|
$throwOnFailure
|
||||||
|
);
|
||||||
|
} catch (GraphException $e) {
|
||||||
|
$primaryException = $e;
|
||||||
|
}
|
||||||
|
|
||||||
// Try primary endpoint
|
if (! empty($assignments)) {
|
||||||
$assignments = $this->fetchPrimary($listPathTemplate, $policyId, $requestOptions);
|
Log::debug('Fetched assignments via primary endpoint', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
'count' => count($assignments),
|
||||||
|
]);
|
||||||
|
|
||||||
if (! empty($assignments)) {
|
return $assignments;
|
||||||
Log::debug('Fetched assignments via primary endpoint', [
|
}
|
||||||
'tenant_id' => $tenantId,
|
|
||||||
'policy_type' => $policyType,
|
|
||||||
'policy_id' => $policyId,
|
|
||||||
'count' => count($assignments),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $assignments;
|
// Try fallback with $expand
|
||||||
}
|
Log::debug('Primary endpoint returned empty, trying fallback', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'policy_id' => $policyId,
|
||||||
|
]);
|
||||||
|
|
||||||
// Try fallback with $expand
|
if (! is_string($resource) || $resource === '') {
|
||||||
Log::debug('Primary endpoint returned empty, trying fallback', [
|
Log::debug('Assignments resource not configured for policy type', [
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'policy_type' => $policyType,
|
'policy_type' => $policyType,
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! is_string($resource) || $resource === '') {
|
if ($throwOnFailure && $primaryException) {
|
||||||
Log::debug('Assignments resource not configured for policy type', [
|
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' => $primaryException->getMessage(),
|
||||||
|
'context' => $primaryException->context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [];
|
throw $primaryException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignments = $this->fetchWithExpand($resource, $policyId, $requestOptions);
|
|
||||||
|
|
||||||
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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallbackException = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$assignments = $this->fetchWithExpand(
|
||||||
|
$resource,
|
||||||
|
$policyId,
|
||||||
|
$requestOptions,
|
||||||
|
$context,
|
||||||
|
$throwOnFailure
|
||||||
|
);
|
||||||
} catch (GraphException $e) {
|
} 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', [
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,9 +340,21 @@ 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);
|
||||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
$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;
|
$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>}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,9 +172,21 @@ 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);
|
||||||
$target['assignment_filter_name'] = $filterNames[$filterId];
|
$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;
|
$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>}
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
@php
|
||||||
Assignments were not captured for this version.
|
$assignmentsFetched = $version->metadata['assignments_fetched'] ?? false;
|
||||||
</p>
|
$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
|
@php
|
||||||
$hasBackupItem = $version->policy->backupItems()
|
$hasBackupItem = $version->policy->backupItems()
|
||||||
->whereNotNull('assignments')
|
->whereNotNull('assignments')
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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]);
|
||||||
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@ -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 () {
|
||||||
|
|||||||
93
tests/Unit/AssignmentBackupServiceTest.php
Normal file
93
tests/Unit/AssignmentBackupServiceTest.php
Normal 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');
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
202
tests/Unit/AssignmentRestoreServiceTest.php
Normal file
202
tests/Unit/AssignmentRestoreServiceTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user