Compare commits
10 Commits
8026a38233
...
7fd614634c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fd614634c | ||
|
|
185962406d | ||
|
|
a7f3293689 | ||
|
|
f09967e3ab | ||
|
|
92bf7af017 | ||
|
|
3eaa99e3f3 | ||
|
|
df4b0efc9b | ||
|
|
6cc296c54c | ||
|
|
cf2aff8188 | ||
|
|
0b8c0983a2 |
@ -51,19 +51,26 @@ public function table(Table $table): Table
|
||||
->label('Assignments')
|
||||
->badge()
|
||||
->color('info')
|
||||
->formatStateUsing(fn ($state) => is_array($state) ? count($state) : 0),
|
||||
->getStateUsing(function (BackupItem $record): int {
|
||||
$assignments = $record->policyVersion?->assignments ?? $record->assignments ?? [];
|
||||
|
||||
return is_array($assignments) ? count($assignments) : 0;
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('scope_tags')
|
||||
->label('Scope Tags')
|
||||
->badge()
|
||||
->separator(',')
|
||||
->default('—')
|
||||
->formatStateUsing(function ($state, BackupItem $record) {
|
||||
// Get scope tags from PolicyVersion if available
|
||||
if ($record->policyVersion && ! empty($record->policyVersion->scope_tags)) {
|
||||
$tags = $record->policyVersion->scope_tags;
|
||||
if (is_array($tags) && isset($tags['names'])) {
|
||||
return implode(', ', $tags['names']);
|
||||
}
|
||||
->getStateUsing(function (BackupItem $record): array {
|
||||
$tags = $record->policyVersion?->scope_tags['names'] ?? [];
|
||||
|
||||
return is_array($tags) ? $tags : [];
|
||||
})
|
||||
->formatStateUsing(function ($state): string {
|
||||
if (is_array($state)) {
|
||||
return $state === [] ? '—' : implode(', ', $state);
|
||||
}
|
||||
|
||||
if (is_string($state) && $state !== '') {
|
||||
return $state;
|
||||
}
|
||||
|
||||
return '—';
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -15,10 +17,13 @@
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
@ -54,6 +59,10 @@ public static function form(Schema $schema): Schema
|
||||
});
|
||||
})
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('backup_item_ids', []);
|
||||
$set('group_mapping', []);
|
||||
})
|
||||
->required(),
|
||||
Forms\Components\CheckboxList::make('backup_item_ids')
|
||||
->label('Items to restore (optional)')
|
||||
@ -86,7 +95,57 @@ public static function form(Schema $schema): Schema
|
||||
});
|
||||
})
|
||||
->columns(2)
|
||||
->reactive()
|
||||
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'),
|
||||
Section::make('Group mapping')
|
||||
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
|
||||
->schema(function (Get $get): array {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$unresolved = static::unresolvedGroups(
|
||||
backupSetId: $backupSetId,
|
||||
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
|
||||
$groupId = $group['id'];
|
||||
$label = $group['label'];
|
||||
|
||||
return Forms\Components\Select::make("group_mapping.{$groupId}")
|
||||
->label($label)
|
||||
->options([
|
||||
'SKIP' => 'Skip assignment',
|
||||
])
|
||||
->searchable()
|
||||
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
|
||||
->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value))
|
||||
->required()
|
||||
->helperText('Choose a target group or select Skip.');
|
||||
}, $unresolved);
|
||||
})
|
||||
->visible(function (Get $get): bool {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return static::unresolvedGroups(
|
||||
backupSetId: $backupSetId,
|
||||
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
||||
tenant: $tenant
|
||||
) !== [];
|
||||
}),
|
||||
Forms\Components\Toggle::make('is_dry_run')
|
||||
->label('Preview only (dry-run)')
|
||||
->default(true),
|
||||
@ -233,6 +292,161 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
dryRun: (bool) ($data['is_dry_run'] ?? true),
|
||||
actorEmail: auth()->user()?->email,
|
||||
actorName: auth()->user()?->name,
|
||||
groupMapping: $data['group_mapping'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $selectedItemIds
|
||||
* @return array<int, array{id:string,label:string}>
|
||||
*/
|
||||
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array
|
||||
{
|
||||
if (! $backupSetId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query = BackupItem::query()->where('backup_set_id', $backupSetId);
|
||||
|
||||
if ($selectedItemIds !== null) {
|
||||
$query->whereIn('id', $selectedItemIds);
|
||||
}
|
||||
|
||||
$items = $query->get(['assignments']);
|
||||
$assignments = [];
|
||||
$sourceNames = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (! is_array($item->assignments) || $item->assignments === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($item->assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $assignment['target'] ?? [];
|
||||
$odataType = $target['@odata.type'] ?? '';
|
||||
|
||||
if (! in_array($odataType, [
|
||||
'#microsoft.graph.groupAssignmentTarget',
|
||||
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||
], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupId = $target['groupId'] ?? null;
|
||||
|
||||
if (! is_string($groupId) || $groupId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignments[] = $groupId;
|
||||
$displayName = $target['group_display_name'] ?? null;
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
$sourceNames[$groupId] = $displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique($assignments));
|
||||
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$graphOptions = $tenant->graphOptions();
|
||||
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||
$resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
|
||||
|
||||
$unresolved = [];
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$group = $resolved[$groupId] ?? null;
|
||||
|
||||
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId);
|
||||
$unresolved[] = [
|
||||
'id' => $groupId,
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
return $unresolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function targetGroupOptions(Tenant $tenant, string $search): array
|
||||
{
|
||||
if (mb_strlen($search) < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$response = app(GraphClientInterface::class)->request(
|
||||
'GET',
|
||||
'groups',
|
||||
[
|
||||
'query' => [
|
||||
'$filter' => sprintf(
|
||||
"securityEnabled eq true and startswith(displayName,'%s')",
|
||||
static::escapeOdataValue($search)
|
||||
),
|
||||
'$select' => 'id,displayName',
|
||||
'$top' => 20,
|
||||
],
|
||||
] + $tenant->graphOptions()
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($response->data['value'] ?? [])
|
||||
->filter(fn (array $group) => filled($group['id'] ?? null))
|
||||
->mapWithKeys(fn (array $group) => [
|
||||
$group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string
|
||||
{
|
||||
if (! $groupId) {
|
||||
return $groupId;
|
||||
}
|
||||
|
||||
if ($groupId === 'SKIP') {
|
||||
return 'Skip assignment';
|
||||
}
|
||||
|
||||
$graphOptions = $tenant->graphOptions();
|
||||
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||
$resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions);
|
||||
$group = $resolved[$groupId] ?? null;
|
||||
|
||||
return static::formatGroupLabel($group['displayName'] ?? null, $groupId);
|
||||
}
|
||||
|
||||
private static function formatGroupLabel(?string $displayName, string $id): string
|
||||
{
|
||||
$suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
|
||||
|
||||
return trim(($displayName ?: 'Security group').$suffix);
|
||||
}
|
||||
|
||||
private static function escapeOdataValue(string $value): string
|
||||
{
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
}
|
||||
|
||||
84
app/Jobs/RestoreAssignmentsJob.php
Normal file
84
app/Jobs/RestoreAssignmentsJob.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RestoreAssignmentsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $backoff = 0;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $restoreRunId,
|
||||
public int $tenantId,
|
||||
public string $policyType,
|
||||
public string $policyId,
|
||||
public array $assignments,
|
||||
public array $groupMapping,
|
||||
public ?string $actorEmail = null,
|
||||
public ?string $actorName = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(AssignmentRestoreService $assignmentRestoreService): array
|
||||
{
|
||||
$restoreRun = RestoreRun::find($this->restoreRunId);
|
||||
$tenant = Tenant::find($this->tenantId);
|
||||
|
||||
if (! $restoreRun || ! $tenant) {
|
||||
Log::warning('RestoreAssignmentsJob missing context', [
|
||||
'restore_run_id' => $this->restoreRunId,
|
||||
'tenant_id' => $this->tenantId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'outcomes' => [],
|
||||
'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
return $assignmentRestoreService->restore(
|
||||
tenant: $tenant,
|
||||
policyType: $this->policyType,
|
||||
policyId: $this->policyId,
|
||||
assignments: $this->assignments,
|
||||
groupMapping: $this->groupMapping,
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $this->actorEmail,
|
||||
actorName: $this->actorName,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('RestoreAssignmentsJob failed', [
|
||||
'restore_run_id' => $this->restoreRunId,
|
||||
'policy_id' => $this->policyId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'outcomes' => [[
|
||||
'status' => 'failed',
|
||||
'reason' => $e->getMessage(),
|
||||
]],
|
||||
'summary' => ['success' => 0, 'failed' => 1, 'skipped' => 0],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,12 +43,16 @@ public function hasGroupMapping(): bool
|
||||
|
||||
public function getMappedGroupId(string $sourceGroupId): ?string
|
||||
{
|
||||
return $this->group_mapping[$sourceGroupId] ?? null;
|
||||
$mapping = $this->group_mapping ?? [];
|
||||
|
||||
return $mapping[$sourceGroupId] ?? null;
|
||||
}
|
||||
|
||||
public function isGroupSkipped(string $sourceGroupId): bool
|
||||
{
|
||||
return $this->group_mapping[$sourceGroupId] === 'SKIP';
|
||||
$mapping = $this->group_mapping ?? [];
|
||||
|
||||
return ($mapping[$sourceGroupId] ?? null) === 'SKIP';
|
||||
}
|
||||
|
||||
public function getUnmappedGroupIds(array $sourceGroupIds): array
|
||||
@ -66,7 +70,22 @@ public function addGroupMapping(string $sourceGroupId, string $targetGroupId): v
|
||||
// Assignment restore outcome helpers
|
||||
public function getAssignmentRestoreOutcomes(): array
|
||||
{
|
||||
return $this->results['assignment_outcomes'] ?? [];
|
||||
$results = $this->results ?? [];
|
||||
|
||||
if (isset($results['assignment_outcomes']) && is_array($results['assignment_outcomes'])) {
|
||||
return $results['assignment_outcomes'];
|
||||
}
|
||||
|
||||
if (! is_array($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($results)
|
||||
->pluck('assignment_outcomes')
|
||||
->flatten(1)
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getSuccessfulAssignmentsCount(): int
|
||||
|
||||
466
app/Services/AssignmentRestoreService.php
Normal file
466
app/Services/AssignmentRestoreService.php
Normal file
@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
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 Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AssignmentRestoreService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GraphClientInterface $graphClient,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly GraphLogger $graphLogger,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
* @param array<string, string> $groupMapping
|
||||
* @return array{outcomes: array<int, array<string, mixed>>, summary: array{success:int,failed:int,skipped:int}}
|
||||
*/
|
||||
public function restore(
|
||||
Tenant $tenant,
|
||||
string $policyType,
|
||||
string $policyId,
|
||||
array $assignments,
|
||||
array $groupMapping,
|
||||
?RestoreRun $restoreRun = null,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
): array {
|
||||
$outcomes = [];
|
||||
$summary = [
|
||||
'success' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
];
|
||||
|
||||
if ($assignments === []) {
|
||||
return [
|
||||
'outcomes' => $outcomes,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId);
|
||||
$createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST'));
|
||||
$usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign');
|
||||
$listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId);
|
||||
$deletePathTemplate = $contract['assignments_delete_path'] ?? null;
|
||||
|
||||
if (! $createPath || (! $usesAssignAction && (! $listPath || ! $deletePathTemplate))) {
|
||||
$outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.');
|
||||
$summary['failed']++;
|
||||
|
||||
return [
|
||||
'outcomes' => $outcomes,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
$graphOptions = $tenant->graphOptions();
|
||||
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||
|
||||
$context = [
|
||||
'tenant' => $tenantIdentifier,
|
||||
'policy_id' => $policyId,
|
||||
'policy_type' => $policyType,
|
||||
'restore_run_id' => $restoreRun?->id,
|
||||
];
|
||||
|
||||
$preparedAssignments = [];
|
||||
$preparedMeta = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupId = $assignment['target']['groupId'] ?? null;
|
||||
$mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null;
|
||||
|
||||
if ($mappedGroupId === 'SKIP') {
|
||||
$outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId);
|
||||
$summary['skipped']++;
|
||||
$this->logAssignmentOutcome(
|
||||
status: 'skipped',
|
||||
tenant: $tenant,
|
||||
assignment: $assignment,
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
metadata: [
|
||||
'policy_id' => $policyId,
|
||||
'policy_type' => $policyType,
|
||||
'group_id' => $groupId,
|
||||
'mapped_group_id' => $mappedGroupId,
|
||||
]
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId);
|
||||
$assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore);
|
||||
|
||||
$preparedAssignments[] = $assignmentToRestore;
|
||||
$preparedMeta[] = [
|
||||
'assignment' => $assignment,
|
||||
'group_id' => $groupId,
|
||||
'mapped_group_id' => $mappedGroupId,
|
||||
];
|
||||
}
|
||||
|
||||
if ($preparedAssignments === []) {
|
||||
return [
|
||||
'outcomes' => $outcomes,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
if ($usesAssignAction) {
|
||||
$this->graphLogger->logRequest('restore_assignments_assign', $context + [
|
||||
'method' => $createMethod,
|
||||
'endpoint' => $createPath,
|
||||
'assignments' => count($preparedAssignments),
|
||||
]);
|
||||
|
||||
$assignResponse = $this->graphClient->request($createMethod, $createPath, [
|
||||
'json' => ['assignments' => $preparedAssignments],
|
||||
] + $graphOptions);
|
||||
|
||||
$this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [
|
||||
'method' => $createMethod,
|
||||
'endpoint' => $createPath,
|
||||
'assignments' => count($preparedAssignments),
|
||||
]);
|
||||
|
||||
if ($assignResponse->successful()) {
|
||||
foreach ($preparedMeta as $meta) {
|
||||
$outcomes[] = $this->successOutcome(
|
||||
$meta['assignment'],
|
||||
$meta['group_id'],
|
||||
$meta['mapped_group_id']
|
||||
);
|
||||
$summary['success']++;
|
||||
$this->logAssignmentOutcome(
|
||||
status: 'created',
|
||||
tenant: $tenant,
|
||||
assignment: $meta['assignment'],
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
metadata: [
|
||||
'policy_id' => $policyId,
|
||||
'policy_type' => $policyType,
|
||||
'group_id' => $meta['group_id'],
|
||||
'mapped_group_id' => $meta['mapped_group_id'],
|
||||
]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed';
|
||||
|
||||
if ($preparedMeta === []) {
|
||||
$outcomes[] = $this->failureOutcome(null, $reason, null, null, $assignResponse);
|
||||
$summary['failed']++;
|
||||
}
|
||||
|
||||
foreach ($preparedMeta as $meta) {
|
||||
$outcomes[] = $this->failureOutcome(
|
||||
$meta['assignment'],
|
||||
$reason,
|
||||
$meta['group_id'],
|
||||
$meta['mapped_group_id'],
|
||||
$assignResponse
|
||||
);
|
||||
$summary['failed']++;
|
||||
$this->logAssignmentOutcome(
|
||||
status: 'failed',
|
||||
tenant: $tenant,
|
||||
assignment: $meta['assignment'],
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
metadata: [
|
||||
'policy_id' => $policyId,
|
||||
'policy_type' => $policyType,
|
||||
'group_id' => $meta['group_id'],
|
||||
'mapped_group_id' => $meta['mapped_group_id'],
|
||||
'graph_error_message' => $assignResponse->meta['error_message'] ?? null,
|
||||
'graph_error_code' => $assignResponse->meta['error_code'] ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'outcomes' => $outcomes,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
$this->graphLogger->logRequest('restore_assignments_list', $context + [
|
||||
'method' => 'GET',
|
||||
'endpoint' => $listPath,
|
||||
]);
|
||||
|
||||
$response = $this->graphClient->request('GET', $listPath, $graphOptions);
|
||||
|
||||
$this->graphLogger->logResponse('restore_assignments_list', $response, $context + [
|
||||
'method' => 'GET',
|
||||
'endpoint' => $listPath,
|
||||
]);
|
||||
|
||||
$existingAssignments = $response->data['value'] ?? [];
|
||||
|
||||
foreach ($existingAssignments as $existing) {
|
||||
$assignmentId = $existing['id'] ?? null;
|
||||
|
||||
if (! is_string($assignmentId) || $assignmentId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId);
|
||||
|
||||
if (! $deletePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->graphLogger->logRequest('restore_assignments_delete', $context + [
|
||||
'method' => 'DELETE',
|
||||
'endpoint' => $deletePath,
|
||||
'assignment_id' => $assignmentId,
|
||||
]);
|
||||
|
||||
$deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions);
|
||||
|
||||
$this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [
|
||||
'method' => 'DELETE',
|
||||
'endpoint' => $deletePath,
|
||||
'assignment_id' => $assignmentId,
|
||||
]);
|
||||
|
||||
if ($deleteResponse->failed()) {
|
||||
Log::warning('Failed to delete existing assignment during restore', $context + [
|
||||
'assignment_id' => $assignmentId,
|
||||
'graph_error_message' => $deleteResponse->meta['error_message'] ?? null,
|
||||
'graph_error_code' => $deleteResponse->meta['error_code'] ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($preparedMeta as $index => $meta) {
|
||||
$assignmentToRestore = $preparedAssignments[$index] ?? null;
|
||||
|
||||
if (! is_array($assignmentToRestore)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->graphLogger->logRequest('restore_assignments_create', $context + [
|
||||
'method' => $createMethod,
|
||||
'endpoint' => $createPath,
|
||||
'group_id' => $meta['group_id'],
|
||||
'mapped_group_id' => $meta['mapped_group_id'],
|
||||
]);
|
||||
|
||||
$createResponse = $this->graphClient->request($createMethod, $createPath, [
|
||||
'json' => $assignmentToRestore,
|
||||
] + $graphOptions);
|
||||
|
||||
$this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [
|
||||
'method' => $createMethod,
|
||||
'endpoint' => $createPath,
|
||||
'group_id' => $meta['group_id'],
|
||||
'mapped_group_id' => $meta['mapped_group_id'],
|
||||
]);
|
||||
|
||||
if ($createResponse->successful()) {
|
||||
$outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']);
|
||||
$summary['success']++;
|
||||
$this->logAssignmentOutcome(
|
||||
status: 'created',
|
||||
tenant: $tenant,
|
||||
assignment: $meta['assignment'],
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
metadata: [
|
||||
'policy_id' => $policyId,
|
||||
'policy_type' => $policyType,
|
||||
'group_id' => $meta['group_id'],
|
||||
'mapped_group_id' => $meta['mapped_group_id'],
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$outcomes[] = $this->failureOutcome(
|
||||
$meta['assignment'],
|
||||
$createResponse->meta['error_message'] ?? 'Graph create failed',
|
||||
$meta['group_id'],
|
||||
$meta['mapped_group_id'],
|
||||
$createResponse
|
||||
);
|
||||
$summary['failed']++;
|
||||
$this->logAssignmentOutcome(
|
||||
status: 'failed',
|
||||
tenant: $tenant,
|
||||
assignment: $meta['assignment'],
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
metadata: [
|
||||
'policy_id' => $policyId,
|
||||
'policy_type' => $policyType,
|
||||
'group_id' => $meta['group_id'],
|
||||
'mapped_group_id' => $meta['mapped_group_id'],
|
||||
'graph_error_message' => $createResponse->meta['error_message'] ?? null,
|
||||
'graph_error_code' => $createResponse->meta['error_code'] ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
usleep(100000);
|
||||
}
|
||||
|
||||
return [
|
||||
'outcomes' => $outcomes,
|
||||
'summary' => $summary,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string
|
||||
{
|
||||
if (! is_string($template) || $template === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = str_replace('{id}', urlencode($policyId), $template);
|
||||
|
||||
if ($assignmentId !== null) {
|
||||
$path = str_replace('{assignmentId}', urlencode($assignmentId), $path);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array
|
||||
{
|
||||
if (! $mappedGroupId) {
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
$target = $assignment['target'] ?? [];
|
||||
$odataType = $target['@odata.type'] ?? '';
|
||||
|
||||
if (in_array($odataType, [
|
||||
'#microsoft.graph.groupAssignmentTarget',
|
||||
'#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||
], true) && isset($target['groupId'])) {
|
||||
$target['groupId'] = $mappedGroupId;
|
||||
$assignment['target'] = $target;
|
||||
}
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
private function sanitizeAssignment(array $assignment): array
|
||||
{
|
||||
$assignment = Arr::except($assignment, ['id']);
|
||||
$target = $assignment['target'] ?? [];
|
||||
|
||||
unset(
|
||||
$target['group_display_name'],
|
||||
$target['group_orphaned'],
|
||||
$target['assignment_filter_name']
|
||||
);
|
||||
|
||||
$assignment['target'] = $target;
|
||||
|
||||
return $assignment;
|
||||
}
|
||||
|
||||
private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
|
||||
{
|
||||
return [
|
||||
'status' => 'success',
|
||||
'assignment' => $this->sanitizeAssignment($assignment),
|
||||
'group_id' => $groupId,
|
||||
'mapped_group_id' => $mappedGroupId,
|
||||
];
|
||||
}
|
||||
|
||||
private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array
|
||||
{
|
||||
return [
|
||||
'status' => 'skipped',
|
||||
'assignment' => $this->sanitizeAssignment($assignment),
|
||||
'group_id' => $groupId,
|
||||
'mapped_group_id' => $mappedGroupId,
|
||||
];
|
||||
}
|
||||
|
||||
private function failureOutcome(
|
||||
?array $assignment,
|
||||
string $reason,
|
||||
?string $groupId = null,
|
||||
?string $mappedGroupId = null,
|
||||
?GraphResponse $response = null
|
||||
): array {
|
||||
return array_filter([
|
||||
'status' => 'failed',
|
||||
'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null,
|
||||
'group_id' => $groupId,
|
||||
'mapped_group_id' => $mappedGroupId,
|
||||
'reason' => $reason,
|
||||
'graph_error_message' => $response?->meta['error_message'] ?? null,
|
||||
'graph_error_code' => $response?->meta['error_code'] ?? null,
|
||||
'graph_request_id' => $response?->meta['request_id'] ?? null,
|
||||
'graph_client_request_id' => $response?->meta['client_request_id'] ?? null,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
private function logAssignmentOutcome(
|
||||
string $status,
|
||||
Tenant $tenant,
|
||||
array $assignment,
|
||||
?RestoreRun $restoreRun,
|
||||
?string $actorEmail,
|
||||
?string $actorName,
|
||||
array $metadata
|
||||
): void {
|
||||
$action = match ($status) {
|
||||
'created' => 'restore.assignment.created',
|
||||
'failed' => 'restore.assignment.failed',
|
||||
default => 'restore.assignment.skipped',
|
||||
};
|
||||
|
||||
$statusLabel = match ($status) {
|
||||
'created' => 'success',
|
||||
'failed' => 'failed',
|
||||
default => 'warning',
|
||||
};
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => $metadata,
|
||||
'assignment' => $this->sanitizeAssignment($assignment),
|
||||
],
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
status: $statusLabel,
|
||||
resourceType: 'restore_run',
|
||||
resourceId: $restoreRun ? (string) $restoreRun->id : null
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\AssignmentRestoreService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphErrorMapper;
|
||||
@ -25,6 +26,7 @@ public function __construct(
|
||||
private readonly VersionService $versionService,
|
||||
private readonly SnapshotValidator $snapshotValidator,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
private readonly AssignmentRestoreService $assignmentRestoreService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -73,6 +75,7 @@ public function execute(
|
||||
bool $dryRun = true,
|
||||
?string $actorEmail = null,
|
||||
?string $actorName = null,
|
||||
array $groupMapping = [],
|
||||
): RestoreRun {
|
||||
$this->assertActiveContext($tenant, $backupSet);
|
||||
|
||||
@ -90,8 +93,28 @@ public function execute(
|
||||
'preview' => $preview,
|
||||
'started_at' => CarbonImmutable::now(),
|
||||
'metadata' => [],
|
||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||
]);
|
||||
|
||||
if ($groupMapping !== []) {
|
||||
$this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'restore.group_mapping.applied',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'restore_run_id' => $restoreRun->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'mapped_groups' => count($groupMapping),
|
||||
],
|
||||
],
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
resourceType: 'restore_run',
|
||||
resourceId: (string) $restoreRun->id,
|
||||
status: 'success'
|
||||
);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$hardFailures = 0;
|
||||
|
||||
@ -265,6 +288,31 @@ public function execute(
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignmentOutcomes = null;
|
||||
$assignmentSummary = null;
|
||||
|
||||
if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) {
|
||||
$assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier;
|
||||
|
||||
$assignmentOutcomes = $this->assignmentRestoreService->restore(
|
||||
tenant: $tenant,
|
||||
policyType: $item->policy_type,
|
||||
policyId: $assignmentPolicyId,
|
||||
assignments: $item->assignments,
|
||||
groupMapping: $groupMapping,
|
||||
restoreRun: $restoreRun,
|
||||
actorEmail: $actorEmail,
|
||||
actorName: $actorName,
|
||||
);
|
||||
|
||||
$assignmentSummary = $assignmentOutcomes['summary'] ?? null;
|
||||
|
||||
if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') {
|
||||
$itemStatus = 'partial';
|
||||
$resultReason = 'Assignments restored with failures';
|
||||
}
|
||||
}
|
||||
|
||||
$result = $context + ['status' => $itemStatus];
|
||||
|
||||
if ($settingsApply !== null) {
|
||||
@ -285,6 +333,14 @@ public function execute(
|
||||
$result['reason'] = 'Some settings require attention';
|
||||
}
|
||||
|
||||
if ($assignmentOutcomes !== null) {
|
||||
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
|
||||
}
|
||||
|
||||
if ($assignmentSummary !== null) {
|
||||
$result['assignment_summary'] = $assignmentSummary;
|
||||
}
|
||||
|
||||
$results[] = $result;
|
||||
|
||||
$appliedPolicyId = $item->policy_identifier;
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
|
||||
// Assignments CRUD (standard Graph pattern)
|
||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
|
||||
@ -42,12 +42,90 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (! empty($item['reason']))
|
||||
@php
|
||||
$itemReason = $item['reason'] ?? null;
|
||||
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
||||
<div class="mt-2 text-sm text-gray-800">
|
||||
{{ $item['reason'] }}
|
||||
{{ $itemReason }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($item['assignment_summary']) && is_array($item['assignment_summary']))
|
||||
@php
|
||||
$summary = $item['assignment_summary'];
|
||||
$assignmentOutcomes = $item['assignment_outcomes'] ?? [];
|
||||
$assignmentIssues = collect($assignmentOutcomes)
|
||||
->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true))
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
<div class="mt-2 text-xs text-gray-700">
|
||||
Assignments: {{ (int) ($summary['success'] ?? 0) }} success •
|
||||
{{ (int) ($summary['failed'] ?? 0) }} failed •
|
||||
{{ (int) ($summary['skipped'] ?? 0) }} skipped
|
||||
</div>
|
||||
|
||||
@if ($assignmentIssues->isNotEmpty())
|
||||
<details class="mt-2 rounded border border-amber-200 bg-amber-50 px-2 py-1 text-xs text-amber-900">
|
||||
<summary class="cursor-pointer font-semibold">Assignment details</summary>
|
||||
<div class="mt-2 space-y-2">
|
||||
@foreach ($assignmentIssues as $outcome)
|
||||
@php
|
||||
$outcomeStatus = $outcome['status'] ?? 'unknown';
|
||||
$outcomeColor = match ($outcomeStatus) {
|
||||
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||
'skipped' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||
};
|
||||
$assignmentGroupId = $outcome['group_id']
|
||||
?? ($outcome['assignment']['target']['groupId'] ?? null);
|
||||
@endphp
|
||||
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Assignment {{ $assignmentGroupId ?? 'unknown group' }}
|
||||
</div>
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
||||
{{ $outcomeStatus }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (! empty($outcome['mapped_group_id']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
Mapped to: {{ $outcome['mapped_group_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@php
|
||||
$outcomeReason = $outcome['reason'] ?? null;
|
||||
$outcomeGraphMessage = $outcome['graph_error_message'] ?? null;
|
||||
@endphp
|
||||
|
||||
@if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
{{ $outcomeReason }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code']))
|
||||
<div class="mt-1 text-[11px] text-amber-900">
|
||||
<div>{{ $outcome['graph_error_message'] ?? 'Unknown error' }}</div>
|
||||
@if (! empty($outcome['graph_error_code']))
|
||||
<div class="mt-0.5 text-amber-800">Code: {{ $outcome['graph_error_code'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if (! empty($item['created_policy_id']))
|
||||
@php
|
||||
$createdMode = $item['created_policy_mode'] ?? null;
|
||||
|
||||
@ -53,23 +53,26 @@
|
||||
@foreach($version->assignments as $assignment)
|
||||
@php
|
||||
$target = $assignment['target'] ?? [];
|
||||
$type = $target['@odata.type'] ?? 'unknown';
|
||||
$type = $target['@odata.type'] ?? '';
|
||||
$typeKey = strtolower((string) $type);
|
||||
$intent = $assignment['intent'] ?? 'apply';
|
||||
|
||||
$typeName = match($type) {
|
||||
'#microsoft.graph.groupAssignmentTarget' => 'Include group',
|
||||
'#microsoft.graph.exclusionGroupAssignmentTarget' => 'Exclude group',
|
||||
'#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users',
|
||||
'#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices',
|
||||
default => 'Unknown'
|
||||
$typeName = match (true) {
|
||||
str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group',
|
||||
str_contains($typeKey, 'groupassignmenttarget') => 'Include group',
|
||||
str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users',
|
||||
str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices',
|
||||
default => 'Unknown',
|
||||
};
|
||||
|
||||
$groupId = $target['groupId'] ?? null;
|
||||
$groupName = $target['group_display_name'] ?? null;
|
||||
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
|
||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none';
|
||||
$filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'));
|
||||
$filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none';
|
||||
$filterName = $target['assignment_filter_name'] ?? null;
|
||||
$filterLabel = $filterName ?? $filterId;
|
||||
@endphp
|
||||
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
@ -96,9 +99,9 @@
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if($filterId && $filterType !== 'none')
|
||||
@if($filterLabel)
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Filter ({{ $filterType }}): {{ $filterName ?? $filterId }}
|
||||
Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
|
||||
</span>
|
||||
@endif
|
||||
|
||||
|
||||
@ -28,7 +28,8 @@ ## Scope
|
||||
- **Policy Types**: `settingsCatalogPolicy` only (initially)
|
||||
- **Graph Endpoints**:
|
||||
- GET `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
- POST/PATCH `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
- POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments)
|
||||
- DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback)
|
||||
- GET `/deviceManagement/roleScopeTags` (for reference data)
|
||||
- GET `/deviceManagement/assignmentFilters` (for filter names)
|
||||
- **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true)
|
||||
@ -141,12 +142,14 @@ ### Backup & Storage
|
||||
**FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy:
|
||||
1. Try: `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
2. If empty/fails: Try `$expand=assignments` on policy fetch
|
||||
3. Continue capture with assignments `null` on failure (fail-soft) and set `assignments_fetch_failed: true`.
|
||||
3. Continue capture with assignments `null` on failure (fail-soft) and set `assignments_fetch_failed: true` in PolicyVersion metadata.
|
||||
- This flag covers any failure during assignment capture/enrichment (fetch, group resolution, filter resolution).
|
||||
|
||||
**FR-004.3**: System MUST enrich assignments with:
|
||||
- Group display name + orphaned flag via `/directoryObjects/getByIds`
|
||||
- Assignment filter name via `/deviceManagement/assignmentFilters`
|
||||
- Preserve target type (include/exclude) and filter mode (`deviceAndAppManagementAssignmentFilterType`)
|
||||
- If filter name lookup fails or filter ID is unknown, keep filter ID + mode, omit the name, and continue capture (UI displays filter ID when name is missing).
|
||||
|
||||
**FR-004.4**: System MUST store assignments and scope tags on PolicyVersion:
|
||||
- `policy_versions.assignments` (array, nullable)
|
||||
@ -154,16 +157,7 @@ ### Backup & Storage
|
||||
- hashes for deduplication (`assignments_hash`, `scope_tags_hash`)
|
||||
BackupItem MUST link to PolicyVersion via `policy_version_id` and copy assignments for restore.
|
||||
|
||||
**FR-004.5**: Capture metadata stored on PolicyVersion and BackupItem MUST include:
|
||||
```json
|
||||
{
|
||||
"has_assignments": true,
|
||||
"has_scope_tags": true,
|
||||
"has_orphaned_assignments": false,
|
||||
"assignments_fetch_failed": false
|
||||
}
|
||||
```
|
||||
Assignment counts are derived from `assignments` at display time.
|
||||
**FR-004.5**: PolicyVersion metadata MUST include capture flags (see Data Model). BackupItem metadata MAY mirror these flags for display/audit, but PolicyVersion is the source of truth. Assignment counts are derived from `assignments` at display time.
|
||||
|
||||
### UI Display
|
||||
|
||||
@ -177,9 +171,14 @@ ### UI Display
|
||||
|
||||
**FR-004.8**: System MUST render orphaned group IDs as "Unknown Group (ID: {id})" with warning icon.
|
||||
|
||||
**Terminology**:
|
||||
- **Orphaned group ID**: A group ID referenced in assignments that cannot be resolved in the source tenant during capture.
|
||||
- **Unresolved group ID**: A group ID not found in the target tenant during restore mapping.
|
||||
- UI SHOULD render both as "Unknown Group (ID: ...)" with warning styling.
|
||||
|
||||
### Restore with Group Mapping
|
||||
|
||||
**FR-004.9**: Restore preview MUST detect unresolved group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved.
|
||||
**FR-004.9**: Restore preview MUST detect unresolved (target-missing) group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved.
|
||||
|
||||
**FR-004.10**: When unresolved groups exist, system MUST inject a "Group Mapping" step in the restore wizard showing:
|
||||
- Source group (name from backup metadata or ID if name unavailable)
|
||||
@ -192,15 +191,18 @@ ### Restore with Group Mapping
|
||||
1. Replace source group IDs with mapped target group IDs in assignment objects
|
||||
2. Skip assignments marked "Skip" in group mapping
|
||||
3. Preserve include/exclude intent and filters
|
||||
4. Execute restore via DELETE-then-CREATE pattern:
|
||||
- Step 1: GET existing assignments from target policy
|
||||
- Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`)
|
||||
- Step 3: POST each new/mapped assignment (via POST `/assignments`)
|
||||
4. Execute restore via assign action when supported:
|
||||
- Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
|
||||
- Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
|
||||
- GET existing assignments from target policy
|
||||
- DELETE each existing assignment (via DELETE `/assignments/{id}`)
|
||||
- POST each new/mapped assignment (via POST `/assignments`)
|
||||
5. Handle failures gracefully:
|
||||
- 204 No Content on DELETE = success
|
||||
- 201 Created on POST = success
|
||||
- Log request-id/client-request-id on any failure
|
||||
6. Continue with remaining assignments if one fails (fail-soft)
|
||||
7. Restore is best-effort: no transactional rollback between DELETE and POST. If DELETE succeeds but POST fails, record a failed outcome, mark the restore as partial, and allow retry.
|
||||
|
||||
**FR-004.13**: System MUST handle assignment restore failures gracefully:
|
||||
- Log per-assignment outcome (success/skip/failure)
|
||||
@ -219,9 +221,9 @@ ### Scope Tags
|
||||
|
||||
**FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour).
|
||||
|
||||
**FR-004.17**: During restore, system SHOULD preserve Scope Tag IDs if they exist in target tenant, or:
|
||||
- Log warning if Scope Tag ID doesn't exist in target
|
||||
- Allow policy creation to proceed (Graph API default behavior)
|
||||
**FR-004.17**: During restore, system MUST preserve Scope Tag IDs that exist in the target tenant. If a Scope Tag ID is missing:
|
||||
- Log a warning
|
||||
- Proceed without that tag (best-effort, Graph API default behavior)
|
||||
|
||||
**FR-004.18**: Restore preview MUST show Scope Tag diff: "Scope Tags: 2 matched, 1 not found in target tenant".
|
||||
|
||||
@ -229,7 +231,7 @@ ### Scope Tags
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
**NFR-004.1**: Assignment fetching MUST NOT block backup creation (async or fail-soft).
|
||||
**NFR-004.1**: Assignment fetching MUST NOT block capture actions (Add Policies / Capture Snapshot). Use async or fail-soft behavior.
|
||||
|
||||
**NFR-004.2**: Group mapping UI MUST support search/filter for tenants with 500+ groups.
|
||||
|
||||
@ -289,7 +291,7 @@ ### `policy_versions.scope_tags` JSONB schema
|
||||
}
|
||||
```
|
||||
|
||||
### `backup_items.metadata` JSONB schema
|
||||
### `policy_versions.metadata` JSONB schema
|
||||
|
||||
```json
|
||||
{
|
||||
@ -300,6 +302,8 @@ ### `backup_items.metadata` JSONB schema
|
||||
}
|
||||
```
|
||||
|
||||
BackupItem metadata MAY include the same flags copied from the PolicyVersion for display/audit, but PolicyVersion is the source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Graph API Integration
|
||||
@ -315,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies)
|
||||
- Client-side filter to extract assignments
|
||||
- **Reason**: Known Graph API quirks with assignment expansion on certain template families
|
||||
|
||||
2. **Assignment CRUD Operations** (Standard Graph Pattern)
|
||||
2. **Assignment Apply** (Assign action + fallback)
|
||||
|
||||
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
- Body: Single assignment object
|
||||
- Returns: 201 Created with assignment object
|
||||
- **POST** `/deviceManagement/configurationPolicies/{id}/assign`
|
||||
- Body: `{ "assignments": [ ... ] }`
|
||||
- Returns: 200/204 on success (no per-assignment IDs)
|
||||
- Example:
|
||||
```json
|
||||
{
|
||||
"target": {
|
||||
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
||||
"groupId": "abc-123-def"
|
||||
},
|
||||
"intent": "apply"
|
||||
"assignments": [
|
||||
{
|
||||
"target": {
|
||||
"@odata.type": "#microsoft.graph.groupAssignmentTarget",
|
||||
"groupId": "abc-123-def"
|
||||
},
|
||||
"intent": "apply"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **Fallback** (when `/assign` is unsupported):
|
||||
- **GET** `/deviceManagement/configurationPolicies/{id}/assignments`
|
||||
- **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
|
||||
- **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object)
|
||||
|
||||
- **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
|
||||
- Body: Assignment object (partial update)
|
||||
- Returns: 200 OK with updated assignment
|
||||
|
||||
- **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}`
|
||||
- Returns: 204 No Content
|
||||
|
||||
- **Restore Strategy**: DELETE all existing assignments, then POST new ones (atomic via transaction pattern)
|
||||
- **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).
|
||||
|
||||
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
|
||||
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
|
||||
@ -371,7 +377,7 @@ ### Graph Contract Updates
|
||||
|
||||
// Assignments CRUD (standard Graph pattern)
|
||||
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assignments',
|
||||
'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign',
|
||||
'assignments_create_method' => 'POST',
|
||||
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
|
||||
'assignments_update_method' => 'PATCH',
|
||||
|
||||
@ -77,7 +77,7 @@ ### Tasks
|
||||
|
||||
**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints
|
||||
- Add `assignments_list_path` (GET)
|
||||
- Add `assignments_create_path` (POST)
|
||||
- Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy)
|
||||
- Add `assignments_delete_path` (DELETE)
|
||||
- Add `supports_scope_tags: true`
|
||||
- Add `scope_tag_field: 'roleScopeTagIds'`
|
||||
@ -351,15 +351,15 @@ ### Tasks
|
||||
**5.8** Create service: `AssignmentRestoreService`
|
||||
- File: `app/Services/AssignmentRestoreService.php`
|
||||
- Method: `restore(string $policyId, array $assignments, array $groupMapping): array`
|
||||
- Implement DELETE-then-CREATE pattern
|
||||
- Prefer `/assign` action when supported; fallback to DELETE-then-CREATE pattern
|
||||
|
||||
**5.9** Implement DELETE existing assignments
|
||||
**5.9** Implement DELETE existing assignments (fallback)
|
||||
- Step 1: GET `/assignments` for target policy
|
||||
- Step 2: Loop and DELETE each assignment
|
||||
- Handle 204 No Content (success)
|
||||
- Log warnings on failure, continue
|
||||
|
||||
**5.10** Implement CREATE new assignments with mapping
|
||||
**5.10** Implement CREATE new assignments with mapping (fallback)
|
||||
- Step 3: Loop through source assignments
|
||||
- Apply group mapping: replace source group IDs with target IDs
|
||||
- Skip assignments marked `"SKIP"` in mapping
|
||||
@ -367,7 +367,7 @@ ### Tasks
|
||||
- Handle 201 Created (success)
|
||||
- Log per-assignment outcome
|
||||
|
||||
**5.11** Add rate limit protection
|
||||
**5.11** Add rate limit protection (fallback only)
|
||||
- Add 100ms delay between sequential POST calls: `usleep(100000)`
|
||||
- Log request IDs for failed calls
|
||||
|
||||
|
||||
@ -169,9 +169,28 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
expect($client->requestCalls[0]['method'])->toBe('POST');
|
||||
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings');
|
||||
|
||||
$results = $run->results;
|
||||
$results[0]['assignment_summary'] = [
|
||||
'success' => 0,
|
||||
'failed' => 1,
|
||||
'skipped' => 0,
|
||||
];
|
||||
$results[0]['assignment_outcomes'] = [[
|
||||
'status' => 'failed',
|
||||
'group_id' => 'group-1',
|
||||
'mapped_group_id' => 'group-2',
|
||||
'reason' => 'Graph create failed',
|
||||
'graph_error_message' => 'Bad request',
|
||||
]];
|
||||
|
||||
$run->update(['results' => $results]);
|
||||
|
||||
$response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run]));
|
||||
$response->assertOk();
|
||||
$response->assertSee('Graph bulk apply failed');
|
||||
$response->assertSee('Setting missing');
|
||||
$response->assertSee('req-setting-404');
|
||||
$response->assertSee('Assignments: 0 success');
|
||||
$response->assertSee('Assignment details');
|
||||
$response->assertSee('Graph create failed');
|
||||
});
|
||||
|
||||
249
tests/Feature/RestoreAssignmentApplicationTest.php
Normal file
249
tests/Feature/RestoreAssignmentApplicationTest.php
Normal file
@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class RestoreAssignmentGraphClient implements GraphClientInterface
|
||||
{
|
||||
/**
|
||||
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||
*/
|
||||
public array $requestCalls = [];
|
||||
|
||||
/**
|
||||
* @param array<int, GraphResponse> $requestResponses
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly GraphResponse $applyPolicyResponse,
|
||||
private array $requestResponses = [],
|
||||
) {}
|
||||
|
||||
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, ['payload' => []]);
|
||||
}
|
||||
|
||||
public function getOrganization(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
||||
{
|
||||
return $this->applyPolicyResponse;
|
||||
}
|
||||
|
||||
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
||||
{
|
||||
return new GraphResponse(true, []);
|
||||
}
|
||||
|
||||
public function request(string $method, string $path, array $options = []): GraphResponse
|
||||
{
|
||||
$this->requestCalls[] = [
|
||||
'method' => strtoupper($method),
|
||||
'path' => $path,
|
||||
'payload' => $options['json'] ?? null,
|
||||
];
|
||||
|
||||
return array_shift($this->requestResponses) ?? new GraphResponse(true, []);
|
||||
}
|
||||
}
|
||||
|
||||
test('restore applies assignments with mapped groups', function () {
|
||||
$applyResponse = new GraphResponse(true, []);
|
||||
$requestResponses = [
|
||||
new GraphResponse(true, []), // assign action
|
||||
];
|
||||
|
||||
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'scp-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog Alpha',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-1',
|
||||
'group_display_name' => 'Source One',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-2',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
groupMapping: [
|
||||
'source-group-1' => 'target-group-1',
|
||||
'source-group-2' => 'target-group-2',
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $run->results[0]['assignment_summary'] ?? null;
|
||||
|
||||
expect($summary)->not->toBeNull();
|
||||
expect($summary['success'])->toBe(2);
|
||||
expect($summary['failed'])->toBe(0);
|
||||
|
||||
$postCalls = collect($client->requestCalls)
|
||||
->filter(fn (array $call) => $call['method'] === 'POST')
|
||||
->values();
|
||||
|
||||
expect($postCalls)->toHaveCount(1);
|
||||
expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign');
|
||||
|
||||
$payloadAssignments = $postCalls[0]['payload']['assignments'] ?? [];
|
||||
$groupIds = collect($payloadAssignments)->pluck('target.groupId')->all();
|
||||
|
||||
expect($groupIds)->toBe(['target-group-1', 'target-group-2']);
|
||||
expect($payloadAssignments[0])->not->toHaveKey('id');
|
||||
});
|
||||
|
||||
test('restore handles assignment failures gracefully', function () {
|
||||
$applyResponse = new GraphResponse(true, []);
|
||||
$requestResponses = [
|
||||
new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [
|
||||
['code' => 'BadRequest', 'message' => 'Bad request'],
|
||||
], [], [
|
||||
'error_code' => 'BadRequest',
|
||||
'error_message' => 'Bad request',
|
||||
]), // assign action fails
|
||||
];
|
||||
|
||||
$client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses);
|
||||
app()->instance(GraphClientInterface::class, $client);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'scp-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog Alpha',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'],
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-1',
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'assignment-2',
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-2',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email' => 'tester@example.com']);
|
||||
$this->actingAs($user);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
$run = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: [$backupItem->id],
|
||||
dryRun: false,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
groupMapping: [
|
||||
'source-group-1' => 'target-group-1',
|
||||
'source-group-2' => 'target-group-2',
|
||||
],
|
||||
);
|
||||
|
||||
$summary = $run->results[0]['assignment_summary'] ?? null;
|
||||
|
||||
expect($summary)->not->toBeNull();
|
||||
expect($summary['success'])->toBe(0);
|
||||
expect($summary['failed'])->toBe(2);
|
||||
expect($run->results[0]['status'])->toBe('partial');
|
||||
});
|
||||
173
tests/Feature/RestoreGroupMappingTest.php
Normal file
173
tests/Feature/RestoreGroupMappingTest.php
Normal file
@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
putenv('INTUNE_TENANT_ID');
|
||||
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||
});
|
||||
|
||||
test('restore wizard shows group mapping for unresolved groups', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => ['id' => $policy->external_id],
|
||||
'assignments' => [[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-1',
|
||||
'group_display_name' => 'Source Group',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->andReturnUsing(function (array $groupIds): array {
|
||||
return collect($groupIds)
|
||||
->mapWithKeys(fn (string $id) => [$id => [
|
||||
'id' => $id,
|
||||
'displayName' => null,
|
||||
'orphaned' => true,
|
||||
]])
|
||||
->all();
|
||||
});
|
||||
});
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CreateRestoreRun::class)
|
||||
->fillForm([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
])
|
||||
->assertFormFieldVisible('group_mapping.source-group-1');
|
||||
});
|
||||
|
||||
test('restore wizard persists group mapping selections', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'payload' => ['id' => $policy->external_id],
|
||||
'assignments' => [[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-1',
|
||||
'group_display_name' => 'Source Group',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->andReturnUsing(function (array $groupIds): array {
|
||||
return collect($groupIds)
|
||||
->mapWithKeys(function (string $id) {
|
||||
$resolved = $id === 'target-group-1';
|
||||
|
||||
return [$id => [
|
||||
'id' => $id,
|
||||
'displayName' => $resolved ? 'Target Group' : null,
|
||||
'orphaned' => ! $resolved,
|
||||
]];
|
||||
})
|
||||
->all();
|
||||
});
|
||||
});
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CreateRestoreRun::class)
|
||||
->fillForm([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
'group_mapping' => [
|
||||
'source-group-1' => 'target-group-1',
|
||||
],
|
||||
'is_dry_run' => true,
|
||||
])
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$run = RestoreRun::first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run->group_mapping)->toBe([
|
||||
'source-group-1' => 'target-group-1',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'restore.group_mapping.applied',
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user