Compare commits

...

10 Commits

Author SHA1 Message Date
Ahmed Darrazi
7fd614634c merge: agent session work 2025-12-23 22:46:21 +01:00
Ahmed Darrazi
185962406d test(004): isolate restore group mapping from env tenant 2025-12-23 22:46:08 +01:00
Ahmed Darrazi
a7f3293689 merge: agent session work 2025-12-23 14:16:14 +01:00
Ahmed Darrazi
f09967e3ab fix(004): align backup item counts with policy version 2025-12-23 14:15:51 +01:00
Ahmed Darrazi
92bf7af017 merge: agent session work 2025-12-23 11:20:58 +01:00
Ahmed Darrazi
3eaa99e3f3 fix(004): use assign action for assignment restore 2025-12-23 11:20:35 +01:00
Ahmed Darrazi
df4b0efc9b feat(004): show assignment outcomes in restore results 2025-12-23 10:51:41 +01:00
Ahmed Darrazi
6cc296c54c merge: agent session work 2025-12-23 03:36:46 +01:00
Ahmed Darrazi
cf2aff8188 feat(004): add restore group mapping and assignment restore 2025-12-23 03:36:15 +01:00
Ahmed Darrazi
0b8c0983a2 docs(004): clarify capture flags and restore semantics 2025-12-23 00:48:21 +01:00
14 changed files with 1445 additions and 71 deletions

View File

@ -51,19 +51,26 @@ public function table(Table $table): Table
->label('Assignments') ->label('Assignments')
->badge() ->badge()
->color('info') ->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') Tables\Columns\TextColumn::make('scope_tags')
->label('Scope Tags') ->label('Scope Tags')
->badge()
->separator(',')
->default('—') ->default('—')
->formatStateUsing(function ($state, BackupItem $record) { ->getStateUsing(function (BackupItem $record): array {
// Get scope tags from PolicyVersion if available $tags = $record->policyVersion?->scope_tags['names'] ?? [];
if ($record->policyVersion && ! empty($record->policyVersion->scope_tags)) {
$tags = $record->policyVersion->scope_tags; return is_array($tags) ? $tags : [];
if (is_array($tags) && isset($tags['names'])) { })
return implode(', ', $tags['names']); ->formatStateUsing(function ($state): string {
} if (is_array($state)) {
return $state === [] ? '—' : implode(', ', $state);
}
if (is_string($state) && $state !== '') {
return $state;
} }
return '—'; return '—';

View File

@ -7,6 +7,8 @@
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
@ -15,10 +17,13 @@
use Filament\Infolists; use Filament\Infolists;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class RestoreRunResource extends Resource class RestoreRunResource extends Resource
@ -54,6 +59,10 @@ public static function form(Schema $schema): Schema
}); });
}) })
->reactive() ->reactive()
->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []);
$set('group_mapping', []);
})
->required(), ->required(),
Forms\Components\CheckboxList::make('backup_item_ids') Forms\Components\CheckboxList::make('backup_item_ids')
->label('Items to restore (optional)') ->label('Items to restore (optional)')
@ -86,7 +95,57 @@ public static function form(Schema $schema): Schema
}); });
}) })
->columns(2) ->columns(2)
->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'), ->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') Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)') ->label('Preview only (dry-run)')
->default(true), ->default(true),
@ -233,6 +292,161 @@ public static function createRestoreRun(array $data): RestoreRun
dryRun: (bool) ($data['is_dry_run'] ?? true), dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email, actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name, 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);
}
} }

View 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],
];
}
}
}

View File

@ -43,12 +43,16 @@ public function hasGroupMapping(): bool
public function getMappedGroupId(string $sourceGroupId): ?string 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 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 public function getUnmappedGroupIds(array $sourceGroupIds): array
@ -66,7 +70,22 @@ public function addGroupMapping(string $sourceGroupId, string $targetGroupId): v
// Assignment restore outcome helpers // Assignment restore outcome helpers
public function getAssignmentRestoreOutcomes(): array 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 public function getSuccessfulAssignmentsCount(): int

View 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
);
}
}

View File

@ -7,6 +7,7 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\RestoreRun; use App\Models\RestoreRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphErrorMapper;
@ -25,6 +26,7 @@ public function __construct(
private readonly VersionService $versionService, private readonly VersionService $versionService,
private readonly SnapshotValidator $snapshotValidator, private readonly SnapshotValidator $snapshotValidator,
private readonly GraphContractRegistry $contracts, private readonly GraphContractRegistry $contracts,
private readonly AssignmentRestoreService $assignmentRestoreService,
) {} ) {}
/** /**
@ -73,6 +75,7 @@ public function execute(
bool $dryRun = true, bool $dryRun = true,
?string $actorEmail = null, ?string $actorEmail = null,
?string $actorName = null, ?string $actorName = null,
array $groupMapping = [],
): RestoreRun { ): RestoreRun {
$this->assertActiveContext($tenant, $backupSet); $this->assertActiveContext($tenant, $backupSet);
@ -90,8 +93,28 @@ public function execute(
'preview' => $preview, 'preview' => $preview,
'started_at' => CarbonImmutable::now(), 'started_at' => CarbonImmutable::now(),
'metadata' => [], '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 = []; $results = [];
$hardFailures = 0; $hardFailures = 0;
@ -265,6 +288,31 @@ public function execute(
continue; 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]; $result = $context + ['status' => $itemStatus];
if ($settingsApply !== null) { if ($settingsApply !== null) {
@ -285,6 +333,14 @@ public function execute(
$result['reason'] = 'Some settings require attention'; $result['reason'] = 'Some settings require attention';
} }
if ($assignmentOutcomes !== null) {
$result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? [];
}
if ($assignmentSummary !== null) {
$result['assignment_summary'] = $assignmentSummary;
}
$results[] = $result; $results[] = $result;
$appliedPolicyId = $item->policy_identifier; $appliedPolicyId = $item->policy_identifier;

View File

@ -73,7 +73,7 @@
// Assignments CRUD (standard Graph pattern) // Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', '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_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',

View File

@ -42,12 +42,90 @@
</span> </span>
</div> </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"> <div class="mt-2 text-sm text-gray-800">
{{ $item['reason'] }} {{ $itemReason }}
</div> </div>
@endif @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'])) @if (! empty($item['created_policy_id']))
@php @php
$createdMode = $item['created_policy_mode'] ?? null; $createdMode = $item['created_policy_mode'] ?? null;

View File

@ -53,23 +53,26 @@
@foreach($version->assignments as $assignment) @foreach($version->assignments as $assignment)
@php @php
$target = $assignment['target'] ?? []; $target = $assignment['target'] ?? [];
$type = $target['@odata.type'] ?? 'unknown'; $type = $target['@odata.type'] ?? '';
$typeKey = strtolower((string) $type);
$intent = $assignment['intent'] ?? 'apply'; $intent = $assignment['intent'] ?? 'apply';
$typeName = match($type) { $typeName = match (true) {
'#microsoft.graph.groupAssignmentTarget' => 'Include group', str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group',
'#microsoft.graph.exclusionGroupAssignmentTarget' => 'Exclude group', str_contains($typeKey, 'groupassignmenttarget') => 'Include group',
'#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users', str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users',
'#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices', str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices',
default => 'Unknown' default => 'Unknown',
}; };
$groupId = $target['groupId'] ?? null; $groupId = $target['groupId'] ?? null;
$groupName = $target['group_display_name'] ?? null; $groupName = $target['group_display_name'] ?? null;
$groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false);
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'; $filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none'));
$filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none';
$filterName = $target['assignment_filter_name'] ?? null; $filterName = $target['assignment_filter_name'] ?? null;
$filterLabel = $filterName ?? $filterId;
@endphp @endphp
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
@ -96,9 +99,9 @@
@endif @endif
@endif @endif
@if($filterId && $filterType !== 'none') @if($filterLabel)
<span class="text-xs text-gray-500 dark:text-gray-500"> <span class="text-xs text-gray-500 dark:text-gray-500">
Filter ({{ $filterType }}): {{ $filterName ?? $filterId }} Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }}
</span> </span>
@endif @endif

View File

@ -28,7 +28,8 @@ ## Scope
- **Policy Types**: `settingsCatalogPolicy` only (initially) - **Policy Types**: `settingsCatalogPolicy` only (initially)
- **Graph Endpoints**: - **Graph Endpoints**:
- GET `/deviceManagement/configurationPolicies/{id}/assignments` - 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/roleScopeTags` (for reference data)
- GET `/deviceManagement/assignmentFilters` (for filter names) - 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) - **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: **FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy:
1. Try: `/deviceManagement/configurationPolicies/{id}/assignments` 1. Try: `/deviceManagement/configurationPolicies/{id}/assignments`
2. If empty/fails: Try `$expand=assignments` on policy fetch 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: **FR-004.3**: System MUST enrich assignments with:
- Group display name + orphaned flag via `/directoryObjects/getByIds` - Group display name + orphaned flag via `/directoryObjects/getByIds`
- Assignment filter name via `/deviceManagement/assignmentFilters` - Assignment filter name via `/deviceManagement/assignmentFilters`
- Preserve target type (include/exclude) and filter mode (`deviceAndAppManagementAssignmentFilterType`) - 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: **FR-004.4**: System MUST store assignments and scope tags on PolicyVersion:
- `policy_versions.assignments` (array, nullable) - `policy_versions.assignments` (array, nullable)
@ -154,16 +157,7 @@ ### Backup & Storage
- hashes for deduplication (`assignments_hash`, `scope_tags_hash`) - hashes for deduplication (`assignments_hash`, `scope_tags_hash`)
BackupItem MUST link to PolicyVersion via `policy_version_id` and copy assignments for restore. 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: **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.
```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.
### UI Display ### 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. **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 ### 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: **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) - 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 1. Replace source group IDs with mapped target group IDs in assignment objects
2. Skip assignments marked "Skip" in group mapping 2. Skip assignments marked "Skip" in group mapping
3. Preserve include/exclude intent and filters 3. Preserve include/exclude intent and filters
4. Execute restore via DELETE-then-CREATE pattern: 4. Execute restore via assign action when supported:
- Step 1: GET existing assignments from target policy - Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments
- Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`) - Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE:
- Step 3: POST each new/mapped assignment (via POST `/assignments`) - 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: 5. Handle failures gracefully:
- 204 No Content on DELETE = success - 204 No Content on DELETE = success
- 201 Created on POST = success - 201 Created on POST = success
- Log request-id/client-request-id on any failure - Log request-id/client-request-id on any failure
6. Continue with remaining assignments if one fails (fail-soft) 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: **FR-004.13**: System MUST handle assignment restore failures gracefully:
- Log per-assignment outcome (success/skip/failure) - 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.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: **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 warning if Scope Tag ID doesn't exist in target - Log a warning
- Allow policy creation to proceed (Graph API default behavior) - 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". **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 ## 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. **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 ```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 ## Graph API Integration
@ -315,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies)
- Client-side filter to extract assignments - Client-side filter to extract assignments
- **Reason**: Known Graph API quirks with assignment expansion on certain template families - **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` - **POST** `/deviceManagement/configurationPolicies/{id}/assign`
- Body: Single assignment object - Body: `{ "assignments": [ ... ] }`
- Returns: 201 Created with assignment object - Returns: 200/204 on success (no per-assignment IDs)
- Example: - Example:
```json ```json
{ {
"target": { "assignments": [
"@odata.type": "#microsoft.graph.groupAssignmentTarget", {
"groupId": "abc-123-def" "target": {
}, "@odata.type": "#microsoft.graph.groupAssignmentTarget",
"intent": "apply" "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}` - **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback).
- 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)
3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) 3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution)
- Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }`
@ -371,7 +377,7 @@ ### Graph Contract Updates
// Assignments CRUD (standard Graph pattern) // Assignments CRUD (standard Graph pattern)
'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', '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_create_method' => 'POST',
'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}',
'assignments_update_method' => 'PATCH', 'assignments_update_method' => 'PATCH',

View File

@ -77,7 +77,7 @@ ### Tasks
**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints **1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints
- Add `assignments_list_path` (GET) - Add `assignments_list_path` (GET)
- Add `assignments_create_path` (POST) - Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy)
- Add `assignments_delete_path` (DELETE) - Add `assignments_delete_path` (DELETE)
- Add `supports_scope_tags: true` - Add `supports_scope_tags: true`
- Add `scope_tag_field: 'roleScopeTagIds'` - Add `scope_tag_field: 'roleScopeTagIds'`
@ -351,15 +351,15 @@ ### Tasks
**5.8** Create service: `AssignmentRestoreService` **5.8** Create service: `AssignmentRestoreService`
- File: `app/Services/AssignmentRestoreService.php` - File: `app/Services/AssignmentRestoreService.php`
- Method: `restore(string $policyId, array $assignments, array $groupMapping): array` - 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 1: GET `/assignments` for target policy
- Step 2: Loop and DELETE each assignment - Step 2: Loop and DELETE each assignment
- Handle 204 No Content (success) - Handle 204 No Content (success)
- Log warnings on failure, continue - 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 - Step 3: Loop through source assignments
- Apply group mapping: replace source group IDs with target IDs - Apply group mapping: replace source group IDs with target IDs
- Skip assignments marked `"SKIP"` in mapping - Skip assignments marked `"SKIP"` in mapping
@ -367,7 +367,7 @@ ### Tasks
- Handle 201 Created (success) - Handle 201 Created (success)
- Log per-assignment outcome - 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)` - Add 100ms delay between sequential POST calls: `usleep(100000)`
- Log request IDs for failed calls - Log request IDs for failed calls

View File

@ -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]['method'])->toBe('POST');
expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings'); 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 = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run]));
$response->assertOk(); $response->assertOk();
$response->assertSee('Graph bulk apply failed'); $response->assertSee('Graph bulk apply failed');
$response->assertSee('Setting missing'); $response->assertSee('Setting missing');
$response->assertSee('req-setting-404'); $response->assertSee('req-setting-404');
$response->assertSee('Assignments: 0 success');
$response->assertSee('Assignment details');
$response->assertSee('Graph create failed');
}); });

View 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');
});

View 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',
]);
});