diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index b0d5664..22796c4 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -362,7 +362,12 @@ private static function typeMeta(?string $type): array return []; } - return collect(config('tenantpilot.supported_policy_types', [])) + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) ->firstWhere('type', $type) ?? []; } diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index fde907c..6c53439 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -26,9 +26,10 @@ public function table(Table $table): Table ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion')) ->columns([ Tables\Columns\TextColumn::make('policy.display_name') - ->label('Policy') + ->label('Item') ->sortable() - ->searchable(), + ->searchable() + ->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()), Tables\Columns\TextColumn::make('policy_type') ->label('Type') ->badge() @@ -112,6 +113,10 @@ public function table(Table $table): Table ->label('Include scope tags') ->default(true) ->helperText('Captures policy scope tag IDs.'), + Forms\Components\Checkbox::make('include_foundations') + ->label('Include foundations') + ->default(true) + ->helperText('Captures assignment filters, scope tags, and notification templates.'), ]) ->action(function (array $data, BackupService $service) { if (empty($data['policy_ids'])) { @@ -134,10 +139,15 @@ public function table(Table $table): Table actorName: auth()->user()?->name, includeAssignments: $data['include_assignments'] ?? false, includeScopeTags: $data['include_scope_tags'] ?? false, + includeFoundations: $data['include_foundations'] ?? false, ); + $notificationTitle = ($data['include_foundations'] ?? false) + ? 'Backup items added' + : 'Policies added to backup'; + Notification::make() - ->title('Policies added to backup') + ->title($notificationTitle) ->success() ->send(); }), @@ -191,7 +201,12 @@ private static function typeMeta(?string $type): array return []; } - return collect(config('tenantpilot.supported_policy_types', [])) + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) ->firstWhere('type', $type) ?? []; } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 12c136b..50087c9 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -75,38 +75,14 @@ public static function form(Schema $schema): Schema ->required(), Forms\Components\CheckboxList::make('backup_item_ids') ->label('Items to restore (optional)') - ->options(function (Get $get) { - $backupSetId = $get('backup_set_id'); - if (! $backupSetId) { - return []; - } - - return BackupItem::query() - ->where('backup_set_id', $backupSetId) - ->whereHas('backupSet', function ($query) { - $tenantId = Tenant::current()->getKey(); - $query->where('tenant_id', $tenantId); - }) - ->get() - ->mapWithKeys(function (BackupItem $item) { - $meta = static::typeMeta($item->policy_type); - $typeLabel = $meta['label'] ?? $item->policy_type; - $restore = $meta['restore'] ?? 'enabled'; - - $label = sprintf( - '%s (%s • restore: %s)', - $item->policy_identifier ?? $item->policy_type, - $typeLabel, - $restore - ); - - return [$item->id => $label]; - }); - }) - ->columns(2) + ->options(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['options']) + ->descriptions(fn (Get $get) => static::restoreItemOptionData($get('backup_set_id'))['descriptions']) + ->columns(1) + ->searchable() + ->bulkToggleable() ->reactive() ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) - ->helperText('Preview-only types stay in dry-run; leave empty to include all items.'), + ->helperText('Search by name, type, or ID. Preview-only types stay in dry-run; leave empty to include all items. Include foundations (scope tags, assignment filters) with policies to re-map IDs.'), 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 { @@ -182,6 +158,75 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ + Actions\Action::make('rerun') + ->label('Rerun') + ->icon('heroicon-o-arrow-path') + ->color('primary') + ->requiresConfirmation() + ->visible(function (RestoreRun $record): bool { + $backupSet = $record->backupSet; + + return $record->isDeletable() + && $backupSet !== null + && ! $backupSet->trashed(); + }) + ->action(function ( + RestoreRun $record, + RestoreService $restoreService, + \App\Services\Intune\AuditLogger $auditLogger + ) { + $tenant = $record->tenant; + $backupSet = $record->backupSet; + + if (! $tenant || ! $backupSet || $backupSet->trashed()) { + Notification::make() + ->title('Restore run cannot be rerun') + ->body('Backup set is archived or unavailable.') + ->warning() + ->send(); + + return; + } + + try { + $newRun = $restoreService->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $record->requested_items ?? null, + dryRun: (bool) $record->is_dry_run, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + groupMapping: $record->group_mapping ?? [] + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + $auditLogger->log( + tenant: $tenant, + action: 'restore_run.rerun', + resourceType: 'restore_run', + resourceId: (string) $newRun->id, + status: 'success', + context: [ + 'metadata' => [ + 'original_restore_run_id' => $record->id, + 'backup_set_id' => $backupSet->id, + ], + ] + ); + + Notification::make() + ->title('Restore run started') + ->success() + ->send(); + }), Actions\Action::make('restore') ->label('Restore') ->color('success') @@ -465,10 +510,82 @@ private static function typeMeta(?string $type): array return []; } - return collect(config('tenantpilot.supported_policy_types', [])) + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) ->firstWhere('type', $type) ?? []; } + /** + * @return array{options: array, descriptions: array} + */ + private static function restoreItemOptionData(?int $backupSetId): array + { + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return [ + 'options' => [], + 'descriptions' => [], + ]; + } + + static $cache = []; + $cacheKey = $tenant->getKey().':'.$backupSetId; + + if (isset($cache[$cacheKey])) { + return $cache[$cacheKey]; + } + + $items = BackupItem::query() + ->where('backup_set_id', $backupSetId) + ->whereHas('backupSet', fn ($query) => $query->where('tenant_id', $tenant->getKey())) + ->with('policy:id,display_name') + ->get() + ->sortBy(function (BackupItem $item) { + $meta = static::typeMeta($item->policy_type); + $category = $meta['category'] ?? 'Policies'; + $categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category; + $name = strtolower($item->resolvedDisplayName()); + + return strtolower($categoryKey.'-'.$name); + }); + + $options = []; + $descriptions = []; + + foreach ($items as $item) { + $meta = static::typeMeta($item->policy_type); + $typeLabel = $meta['label'] ?? $item->policy_type; + $category = $meta['category'] ?? 'Policies'; + $restore = $meta['restore'] ?? 'enabled'; + $platform = $item->platform ?? $meta['platform'] ?? null; + $displayName = $item->resolvedDisplayName(); + $identifier = $item->policy_identifier ?? null; + + $options[$item->id] = $displayName; + + $parts = array_filter([ + $category, + $typeLabel, + $platform, + "restore: {$restore}", + $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, + $identifier ? 'id: '.Str::limit($identifier, 24, '...') : null, + ]); + + $descriptions[$item->id] = implode(' • ', $parts); + } + + return $cache[$cacheKey] = [ + 'options' => $options, + 'descriptions' => $descriptions, + ]; + } + public static function createRestoreRun(array $data): RestoreRun { /** @var Tenant $tenant */ diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 7f7baf3..dd61427 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -298,7 +298,7 @@ public static function infolist(Schema $schema): Schema ->copyable(), Infolists\Components\RepeatableEntry::make('permissions') ->label('Required permissions') - ->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false)['permissions']) + ->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false, useConfiguredStub: false)['permissions']) ->schema([ Infolists\Components\TextEntry::make('key')->label('Permission')->badge(), Infolists\Components\TextEntry::make('type')->badge(), diff --git a/app/Jobs/RestoreAssignmentsJob.php b/app/Jobs/RestoreAssignmentsJob.php index 5bdec75..ed20750 100644 --- a/app/Jobs/RestoreAssignmentsJob.php +++ b/app/Jobs/RestoreAssignmentsJob.php @@ -30,6 +30,7 @@ public function __construct( public string $policyId, public array $assignments, public array $groupMapping, + public array $foundationMapping = [], public ?string $actorEmail = null, public ?string $actorName = null, ) {} @@ -61,6 +62,7 @@ public function handle(AssignmentRestoreService $assignmentRestoreService): arra policyId: $this->policyId, assignments: $this->assignments, groupMapping: $this->groupMapping, + foundationMapping: $this->foundationMapping, restoreRun: $restoreRun, actorEmail: $this->actorEmail, actorName: $this->actorName, diff --git a/app/Models/BackupItem.php b/app/Models/BackupItem.php index 9f9ad2c..e1696a9 100644 --- a/app/Models/BackupItem.php +++ b/app/Models/BackupItem.php @@ -84,6 +84,34 @@ public function assignmentsFetchFailed(): bool return $this->metadata['assignments_fetch_failed'] ?? false; } + public function isFoundation(): bool + { + $types = array_column(config('tenantpilot.foundation_types', []), 'type'); + + return in_array($this->policy_type, $types, true); + } + + public function resolvedDisplayName(): string + { + if ($this->policy) { + return $this->policy->display_name; + } + + $metadata = $this->metadata ?? []; + $payload = is_array($this->payload) ? $this->payload : []; + $name = $metadata['displayName'] + ?? $metadata['display_name'] + ?? $payload['displayName'] + ?? $payload['name'] + ?? null; + + if (is_string($name) && $name !== '') { + return $name; + } + + return $this->policy_identifier; + } + // Scopes public function scopeWithAssignments($query) { diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php index 1505be7..6d32690 100644 --- a/app/Services/AssignmentRestoreService.php +++ b/app/Services/AssignmentRestoreService.php @@ -24,6 +24,7 @@ public function __construct( /** * @param array> $assignments * @param array $groupMapping + * @param array> $foundationMapping * @return array{outcomes: array>, summary: array{success:int,failed:int,skipped:int}} */ public function restore( @@ -32,6 +33,7 @@ public function restore( string $policyId, array $assignments, array $groupMapping, + array $foundationMapping = [], ?RestoreRun $restoreRun = null, ?string $actorEmail = null, ?string $actorName = null, @@ -80,11 +82,70 @@ public function restore( $preparedAssignments = []; $preparedMeta = []; + $assignmentFilterMapping = $foundationMapping['assignmentFilter'] ?? []; + foreach ($assignments as $assignment) { if (! is_array($assignment)) { continue; } + $target = $assignment['target'] ?? []; + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + + if ($filterId !== null) { + if ($assignmentFilterMapping === []) { + $outcomes[] = $this->skipOutcome($assignment, null, null, 'Assignment filter mapping is unavailable.'); + $summary['skipped']++; + $this->logAssignmentOutcome( + status: 'skipped', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'assignment_filter_id' => $filterId, + 'reason' => 'Assignment filter mapping is unavailable.', + ] + ); + + continue; + } + + $mappedFilterId = $assignmentFilterMapping[$filterId] ?? null; + + if ($mappedFilterId === null) { + $outcomes[] = $this->skipOutcome( + $assignment, + null, + null, + 'Assignment filter mapping missing for filter ID.' + ); + $summary['skipped']++; + $this->logAssignmentOutcome( + status: 'skipped', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'assignment_filter_id' => $filterId, + 'reason' => 'Assignment filter mapping missing for filter ID.', + ] + ); + + continue; + } + + $target['deviceAndAppManagementAssignmentFilterId'] = $mappedFilterId; + $assignment['target'] = $target; + } + $groupId = $assignment['target']['groupId'] ?? null; $mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null; @@ -398,13 +459,18 @@ private function successOutcome(array $assignment, ?string $groupId, ?string $ma ]; } - private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array - { + private function skipOutcome( + array $assignment, + ?string $groupId, + ?string $mappedGroupId, + ?string $reason = null + ): array { return [ 'status' => 'skipped', 'assignment' => $this->sanitizeAssignment($assignment), 'group_id' => $groupId, 'mapped_group_id' => $mappedGroupId, + 'reason' => $reason, ]; } diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index dd8aee4..b41dd01 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -19,6 +19,7 @@ public function __construct( private readonly PolicySnapshotService $snapshotService, private readonly AssignmentBackupService $assignmentBackupService, private readonly PolicyCaptureOrchestrator $captureOrchestrator, + private readonly FoundationSnapshotService $foundationSnapshots, ) {} /** @@ -34,6 +35,7 @@ public function createBackupSet( ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, + bool $includeFoundations = false, ): BackupSet { $this->assertActiveTenant($tenant); @@ -42,7 +44,7 @@ public function createBackupSet( ->whereIn('id', $policyIds) ->get(); - $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags) { + $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags, $includeFoundations) { $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup', @@ -75,6 +77,12 @@ public function createBackupSet( } } + if ($includeFoundations) { + $foundationOutcome = $this->captureFoundations($tenant, $backupSet); + $itemsCreated += $foundationOutcome['created'] + $foundationOutcome['restored']; + $failures = array_merge($failures, $foundationOutcome['failures']); + } + $status = $this->resolveStatus($itemsCreated, $failures); $backupSet->update([ @@ -145,6 +153,7 @@ public function addPoliciesToSet( ?string $actorName = null, bool $includeAssignments = false, bool $includeScopeTags = false, + bool $includeFoundations = false, ): BackupSet { $this->assertActiveTenant($tenant); @@ -200,6 +209,12 @@ public function addPoliciesToSet( } } + if ($includeFoundations) { + $foundationOutcome = $this->captureFoundations($tenant, $backupSet); + $itemsCreated += $foundationOutcome['created'] + $foundationOutcome['restored']; + $failures = array_merge($failures, $foundationOutcome['failures']); + } + $status = $this->resolveStatus($itemsCreated, $failures); $backupSet->update([ @@ -307,6 +322,70 @@ private function snapshotPolicy( return [$backupItem, null]; } + /** + * @return array{created:int,restored:int,failures:array} + */ + private function captureFoundations(Tenant $tenant, BackupSet $backupSet): array + { + $types = config('tenantpilot.foundation_types', []); + $created = 0; + $restored = 0; + $failures = []; + + foreach ($types as $typeConfig) { + $foundationType = $typeConfig['type'] ?? null; + + if (! is_string($foundationType) || $foundationType === '') { + continue; + } + + $result = $this->foundationSnapshots->fetchAll($tenant, $foundationType); + $failures = array_merge($failures, $result['failures'] ?? []); + + foreach ($result['items'] as $snapshot) { + $sourceId = $snapshot['source_id'] ?? null; + + if (! is_string($sourceId) || $sourceId === '') { + continue; + } + + $existing = BackupItem::withTrashed() + ->where('backup_set_id', $backupSet->id) + ->where('policy_type', $foundationType) + ->where('policy_identifier', $sourceId) + ->first(); + + if ($existing) { + if ($existing->trashed()) { + $existing->restore(); + $restored++; + } + + continue; + } + + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => $sourceId, + 'policy_type' => $foundationType, + 'platform' => $typeConfig['platform'] ?? null, + 'payload' => $snapshot['payload'], + 'metadata' => $snapshot['metadata'] ?? [], + ]); + + $created++; + } + } + + return [ + 'created' => $created, + 'restored' => $restored, + 'failures' => $failures, + ]; + } + private function assertActiveTenant(Tenant $tenant): void { if (! $tenant->isActive()) { diff --git a/app/Services/Intune/FoundationMappingService.php b/app/Services/Intune/FoundationMappingService.php new file mode 100644 index 0000000..3a93918 --- /dev/null +++ b/app/Services/Intune/FoundationMappingService.php @@ -0,0 +1,372 @@ + $items + * @return array{entries: array>, mapping: array, failed: int, skipped: int} + */ + public function map(Tenant $tenant, Collection $items, bool $execute = false): array + { + $entries = []; + $mapping = []; + $failed = 0; + $skipped = 0; + + if ($items->isEmpty()) { + return [ + 'entries' => $entries, + 'mapping' => $mapping, + 'failed' => $failed, + 'skipped' => $skipped, + ]; + } + + $itemsByType = $items->groupBy('policy_type'); + + foreach ($itemsByType as $foundationType => $typeItems) { + $foundationType = (string) $foundationType; + $existingOutcome = $this->snapshotService->fetchAll($tenant, $foundationType); + $existingFailures = $existingOutcome['failures'] ?? []; + + if (! empty($existingFailures)) { + $reason = $existingFailures[0]['reason'] ?? 'Unable to list foundation resources.'; + + foreach ($typeItems as $item) { + $entries[] = $this->failureEntry($foundationType, $item, $reason); + $failed++; + } + + continue; + } + + $existing = $existingOutcome['items'] ?? []; + $existingByName = $this->indexExistingByName($existing); + $existingNames = array_keys($existingByName); + + foreach ($typeItems as $item) { + $sourceId = $item->policy_identifier; + $sourceName = $item->resolvedDisplayName(); + + if ($sourceName === '') { + $entries[] = $this->skipEntry($foundationType, $sourceId, null, 'Missing display name.'); + $skipped++; + + continue; + } + + $normalizedName = strtolower($sourceName); + $matches = $existingByName[$normalizedName] ?? []; + + if (count($matches) === 1) { + $match = $matches[0]; + $targetId = $match['source_id'] ?? null; + $targetName = $match['display_name'] ?? null; + + if (is_string($targetId) && $targetId !== '') { + $mapping[$sourceId] = $targetId; + } + + $entries[] = $this->decisionEntry( + $foundationType, + $sourceId, + $sourceName, + 'mapped_existing', + $targetId, + $targetName, + null + ); + + continue; + } + + $builtIn = $this->isBuiltInScopeTag($foundationType, $item); + + if ($builtIn) { + $entries[] = $this->skipEntry( + $foundationType, + $sourceId, + $sourceName, + 'Built-in scope tag cannot be created.' + ); + $skipped++; + + continue; + } + + $decision = count($matches) > 1 ? 'created_copy' : 'created'; + $targetName = $decision === 'created_copy' + ? $this->resolveCopyName($sourceName, $existingNames) + : $sourceName; + + if (! $execute) { + $entries[] = $this->decisionEntry( + $foundationType, + $sourceId, + $sourceName, + $decision, + null, + $targetName, + count($matches) > 1 ? 'Multiple matches found by name.' : null + ); + + continue; + } + + $createOutcome = $this->createFoundation( + tenant: $tenant, + foundationType: $foundationType, + item: $item, + targetName: $targetName + ); + + if ($createOutcome['success'] ?? false) { + $targetId = $createOutcome['target_id'] ?? null; + $createdName = $createOutcome['target_name'] ?? $targetName; + + if (is_string($targetId) && $targetId !== '') { + $mapping[$sourceId] = $targetId; + } + + $entries[] = $this->decisionEntry( + $foundationType, + $sourceId, + $sourceName, + $decision, + $targetId, + $createdName, + null + ); + + continue; + } + + $entries[] = $this->failureEntry( + $foundationType, + $item, + $createOutcome['reason'] ?? 'Failed to create foundation resource.' + ); + $failed++; + } + } + + return [ + 'entries' => $entries, + 'mapping' => $mapping, + 'failed' => $failed, + 'skipped' => $skipped, + ]; + } + + /** + * @param array $existing + * @return array> + */ + private function indexExistingByName(array $existing): array + { + $index = []; + + foreach ($existing as $item) { + $name = $item['display_name'] ?? null; + + if (! is_string($name) || $name === '') { + continue; + } + + $index[strtolower($name)][] = [ + 'source_id' => $item['source_id'], + 'display_name' => $name, + ]; + } + + return $index; + } + + private function resolveCopyName(string $baseName, array $existingNames): string + { + $suffix = ' (Copy)'; + $candidate = $baseName.$suffix; + $normalized = array_map('strtolower', $existingNames); + + if (! in_array(strtolower($candidate), $normalized, true)) { + return $candidate; + } + + $counter = 2; + + while (true) { + $candidate = sprintf('%s (Copy %d)', $baseName, $counter); + + if (! in_array(strtolower($candidate), $normalized, true)) { + return $candidate; + } + + $counter++; + } + } + + private function isBuiltInScopeTag(string $foundationType, BackupItem $item): bool + { + if ($foundationType !== 'roleScopeTag') { + return false; + } + + if ($item->policy_identifier === '0') { + return true; + } + + $payload = is_array($item->payload) ? $item->payload : []; + + return (bool) ($payload['isBuiltIn'] ?? false); + } + + /** + * @return array{success: bool, target_id: ?string, target_name: ?string, reason: ?string} + */ + private function createFoundation( + Tenant $tenant, + string $foundationType, + BackupItem $item, + string $targetName + ): array { + $resource = $this->contracts->resourcePath($foundationType); + + if (! $resource) { + return [ + 'success' => false, + 'target_id' => null, + 'target_name' => null, + 'reason' => 'Graph contract resource missing for foundation type.', + ]; + } + + $contract = $this->contracts->get($foundationType); + $method = strtoupper((string) ($contract['create_method'] ?? 'POST')); + $payload = $this->contracts->sanitizeUpdatePayload($foundationType, is_array($item->payload) ? $item->payload : []); + $payload = $this->applyDisplayName($payload, $targetName); + + if ($payload === []) { + return [ + 'success' => false, + 'target_id' => null, + 'target_name' => null, + 'reason' => 'Foundation payload could not be sanitized.', + ]; + } + + $response = $this->graphClient->request( + $method, + $resource, + ['json' => $payload] + $tenant->graphOptions() + ); + + if ($response->failed()) { + return [ + 'success' => false, + 'target_id' => null, + 'target_name' => null, + 'reason' => $response->meta['error_message'] ?? 'Graph create failed.', + ]; + } + + $data = $response->data; + $targetId = is_array($data) ? ($data['id'] ?? null) : null; + $targetName = is_array($data) ? ($data['displayName'] ?? $data['name'] ?? $targetName) : $targetName; + + return [ + 'success' => true, + 'target_id' => is_string($targetId) ? $targetId : null, + 'target_name' => is_string($targetName) ? $targetName : $targetName, + 'reason' => null, + ]; + } + + /** + * @param array $payload + * @return array + */ + private function applyDisplayName(array $payload, string $targetName): array + { + if (array_key_exists('displayName', $payload)) { + $payload['displayName'] = $targetName; + + return $payload; + } + + if (array_key_exists('name', $payload)) { + $payload['name'] = $targetName; + + return $payload; + } + + $payload['displayName'] = $targetName; + + return $payload; + } + + private function decisionEntry( + string $foundationType, + string $sourceId, + string $sourceName, + string $decision, + ?string $targetId, + ?string $targetName, + ?string $reason + ): array { + return array_filter([ + 'type' => $foundationType, + 'sourceId' => $sourceId, + 'sourceName' => $sourceName, + 'decision' => $decision, + 'targetId' => $targetId, + 'targetName' => $targetName, + 'reason' => $reason, + ], static fn ($value) => $value !== null); + } + + private function skipEntry( + string $foundationType, + string $sourceId, + ?string $sourceName, + string $reason + ): array { + return $this->decisionEntry( + $foundationType, + $sourceId, + $sourceName ?? $sourceId, + 'skipped', + null, + null, + $reason + ); + } + + private function failureEntry(string $foundationType, BackupItem $item, string $reason): array + { + $sourceName = $item->resolvedDisplayName(); + + return $this->decisionEntry( + $foundationType, + $item->policy_identifier, + $sourceName, + 'failed', + null, + null, + $reason + ); + } +} diff --git a/app/Services/Intune/FoundationSnapshotService.php b/app/Services/Intune/FoundationSnapshotService.php new file mode 100644 index 0000000..33d4e03 --- /dev/null +++ b/app/Services/Intune/FoundationSnapshotService.php @@ -0,0 +1,121 @@ +, failures: array} + */ + public function fetchAll(Tenant $tenant, string $foundationType): array + { + $resource = $this->contracts->resourcePath($foundationType); + + if (! $resource) { + return [ + 'items' => [], + 'failures' => [[ + 'foundation_type' => $foundationType, + 'reason' => 'Graph contract resource missing for foundation type.', + 'status' => null, + ]], + ]; + } + + $contract = $this->contracts->get($foundationType); + $query = []; + + if (! empty($contract['allowed_select']) && is_array($contract['allowed_select'])) { + $query['$select'] = $contract['allowed_select']; + } + + $sanitized = $this->contracts->sanitizeQuery($foundationType, $query); + $options = $tenant->graphOptions(); + $items = []; + $failures = []; + $nextPath = $resource; + $useQuery = $sanitized['query'] ?? []; + + while ($nextPath) { + $response = $this->graphClient->request('GET', $nextPath, $options + [ + 'query' => $useQuery, + ]); + + if ($response->failed()) { + $failures[] = [ + 'foundation_type' => $foundationType, + 'reason' => $response->meta['error_message'] ?? $response->warnings[0] ?? 'Graph request failed.', + 'status' => $response->status, + ]; + + break; + } + + $data = $response->data; + $pageItems = $data['value'] ?? (is_array($data) ? $data : []); + + foreach ($pageItems as $item) { + if (! is_array($item)) { + continue; + } + + $sourceId = $item['id'] ?? null; + + if (! is_string($sourceId) || $sourceId === '') { + continue; + } + + $displayName = $item['displayName'] ?? $item['name'] ?? null; + + $items[] = [ + 'source_id' => $sourceId, + 'display_name' => is_string($displayName) ? $displayName : null, + 'payload' => $item, + 'metadata' => [ + 'displayName' => is_string($displayName) ? $displayName : null, + 'kind' => $foundationType, + 'graph' => [ + 'resource' => $resource, + 'apiVersion' => config('graph.version', 'beta'), + ], + ], + ]; + } + + $nextLink = $data['@odata.nextLink'] ?? null; + + if (! $nextLink) { + break; + } + + $nextPath = $this->stripGraphBaseUrl((string) $nextLink); + $useQuery = []; + } + + return [ + 'items' => $items, + 'failures' => $failures, + ]; + } + + private function stripGraphBaseUrl(string $nextLink): string + { + $base = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim(config('graph.version', 'beta'), '/'); + + if (str_starts_with($nextLink, $base)) { + return ltrim(substr($nextLink, strlen($base)), '/'); + } + + return ltrim($nextLink, '/'); + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 71a8210..06622fc 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -27,6 +27,7 @@ public function __construct( private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, private readonly AssignmentRestoreService $assignmentRestoreService, + private readonly FoundationMappingService $foundationMappingService, ) {} /** @@ -40,13 +41,19 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt $items = $this->loadItems($backupSet, $selectedItemIds); - return $items->map(function (BackupItem $item) use ($tenant) { + [$foundationItems, $policyItems] = $this->splitItems($items); + + $foundationPreview = $this->foundationMappingService->map($tenant, $foundationItems, false)['entries'] ?? []; + + $policyPreview = $policyItems->map(function (BackupItem $item) use ($tenant) { $existing = Policy::query() ->where('tenant_id', $tenant->id) ->where('external_id', $item->policy_identifier) ->where('policy_type', $item->policy_type) ->first(); + $restoreMode = $this->resolveRestoreMode($item->policy_type); + return [ 'backup_item_id' => $item->id, 'policy_identifier' => $item->policy_identifier, @@ -54,6 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt 'platform' => $item->platform, 'action' => $existing ? 'update' : 'create', 'conflict' => false, + 'restore_mode' => $restoreMode, 'validation_warning' => BackupItem::odataTypeWarning( is_array($item->payload) ? $item->payload : [], $item->policy_type, @@ -61,6 +69,8 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt ) ?? ($this->snapshotValidator->validate(is_array($item->payload) ? $item->payload : [])['warnings'][0] ?? null), ]; })->all(); + + return array_merge($foundationPreview, $policyPreview); } /** @@ -81,6 +91,7 @@ public function execute( $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); + [$foundationItems, $policyItems] = $this->splitItems($items); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); $restoreRun = RestoreRun::create([ @@ -115,10 +126,27 @@ public function execute( ); } - $results = []; - $hardFailures = 0; + $foundationOutcome = $this->foundationMappingService->map($tenant, $foundationItems, ! $dryRun); + $foundationEntries = $foundationOutcome['entries'] ?? []; + $foundationFailures = (int) ($foundationOutcome['failed'] ?? 0); + $foundationSkipped = (int) ($foundationOutcome['skipped'] ?? 0); + $foundationMappingByType = $this->buildFoundationMappingByType($foundationEntries); + $scopeTagMapping = $foundationMappingByType['roleScopeTag'] ?? []; - foreach ($items as $item) { + if (! $dryRun) { + $this->auditFoundationMapping( + tenant: $tenant, + restoreRun: $restoreRun, + entries: $foundationEntries, + actorEmail: $actorEmail, + actorName: $actorName + ); + } + + $results = $foundationEntries; + $hardFailures = $foundationFailures; + + foreach ($policyItems as $item) { $context = [ 'tenant' => $tenantIdentifier, 'policy_type' => $item->policy_type, @@ -126,6 +154,18 @@ public function execute( 'backup_item_id' => $item->id, ]; + $restoreMode = $this->resolveRestoreMode($item->policy_type); + + if ($restoreMode === 'preview-only') { + $results[] = $context + [ + 'status' => $dryRun ? 'dry_run' : 'skipped', + 'reason' => 'preview_only', + 'restore_mode' => $restoreMode, + ]; + + continue; + } + $odataValidation = BackupItem::validateODataType( is_array($item->payload) ? $item->payload : [], $item->policy_type, @@ -148,7 +188,10 @@ public function execute( } if ($dryRun) { - $results[] = $context + ['status' => 'dry_run']; + $results[] = $context + [ + 'status' => 'dry_run', + 'restore_mode' => $restoreMode, + ]; continue; } @@ -157,9 +200,12 @@ public function execute( try { $originalPayload = is_array($item->payload) ? $item->payload : []; + $originalPayload = $this->applyScopeTagMapping($originalPayload, $scopeTagMapping); + $mappedScopeTagIds = $this->resolvePayloadArray($originalPayload, ['roleScopeTagIds', 'RoleScopeTagIds']); // sanitize high-level fields according to contract $payload = $this->contracts->sanitizeUpdatePayload($item->policy_type, $originalPayload); + $payload = $this->applyScopeTagIdsToPayload($payload, $mappedScopeTagIds, $scopeTagMapping); $graphOptions = [ 'tenant' => $tenantIdentifier, @@ -300,6 +346,7 @@ public function execute( policyId: $assignmentPolicyId, assignments: $item->assignments, groupMapping: $groupMapping, + foundationMapping: $foundationMappingByType, restoreRun: $restoreRun, actorEmail: $actorEmail, actorName: $actorName, @@ -313,7 +360,10 @@ public function execute( } } - $result = $context + ['status' => $itemStatus]; + $result = $context + [ + 'status' => $itemStatus, + 'restore_mode' => $restoreMode, + ]; if ($settingsApply !== null) { $result['settings_apply'] = $settingsApply; @@ -366,8 +416,16 @@ public function execute( } $resultStatuses = collect($results)->pluck('status')->all(); - $nonApplied = collect($resultStatuses)->filter(fn (string $status) => $status !== 'applied' && $status !== 'dry_run')->count(); - $allHardFailed = count($results) > 0 && $hardFailures === count($results); + $nonApplied = collect($resultStatuses)->filter(fn ($status) => is_string($status) && $status !== 'applied' && $status !== 'dry_run')->count(); + $foundationNonApplied = collect($foundationEntries)->filter(function (array $entry): bool { + $decision = $entry['decision'] ?? null; + + return in_array($decision, ['failed', 'skipped'], true); + })->count(); + + $nonApplied += $foundationNonApplied; + $totalCount = count($results); + $allHardFailed = $totalCount > 0 && $hardFailures === $totalCount; $status = $dryRun ? 'previewed' @@ -384,7 +442,8 @@ public function execute( 'metadata' => [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, - 'total' => count($results), + 'total' => $totalCount, + 'foundations_skipped' => $foundationSkipped, ], ]); @@ -409,6 +468,191 @@ public function execute( return $restoreRun->refresh(); } + /** + * @param Collection $items + * @return array{0: Collection, 1: Collection} + */ + private function splitItems(Collection $items): array + { + $foundationItems = $items->filter(fn (BackupItem $item) => $item->isFoundation())->values(); + $policyItems = $items->reject(fn (BackupItem $item) => $item->isFoundation())->values(); + + return [$foundationItems, $policyItems]; + } + + /** + * @return array + */ + private function resolveTypeMeta(string $policyType): array + { + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + foreach ($types as $typeConfig) { + if (($typeConfig['type'] ?? null) === $policyType) { + return $typeConfig; + } + } + + return []; + } + + private function resolveRestoreMode(string $policyType): string + { + $meta = $this->resolveTypeMeta($policyType); + $restore = $meta['restore'] ?? 'enabled'; + + if (! is_string($restore) || $restore === '') { + return 'enabled'; + } + + return $restore; + } + + /** + * @param array> $entries + * @return array> + */ + private function buildFoundationMappingByType(array $entries): array + { + $mapping = []; + + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $type = $entry['type'] ?? null; + $sourceId = $entry['sourceId'] ?? null; + $targetId = $entry['targetId'] ?? null; + + if (! is_string($type) || $type === '') { + continue; + } + + if (! is_string($sourceId) || $sourceId === '') { + continue; + } + + if (! is_string($targetId) || $targetId === '') { + continue; + } + + $mapping[$type][$sourceId] = $targetId; + } + + return $mapping; + } + + /** + * @param array $payload + * @param array $scopeTagMapping + * @return array + */ + private function applyScopeTagMapping(array $payload, array $scopeTagMapping): array + { + if ($scopeTagMapping === []) { + return $payload; + } + + $roleScopeTagIds = $this->resolvePayloadArray($payload, ['roleScopeTagIds', 'RoleScopeTagIds']); + + if ($roleScopeTagIds === null) { + return $payload; + } + + $mapped = []; + + foreach ($roleScopeTagIds as $id) { + if (is_string($id) || is_int($id)) { + $stringId = (string) $id; + $mapped[] = $scopeTagMapping[$stringId] ?? $stringId; + } + } + + if ($mapped === []) { + return $payload; + } + + $payload['roleScopeTagIds'] = array_values(array_unique($mapped)); + unset($payload['RoleScopeTagIds']); + + return $payload; + } + + /** + * @param array $payload + * @param array|null $scopeTagIds + * @param array $scopeTagMapping + * @return array + */ + private function applyScopeTagIdsToPayload(array $payload, ?array $scopeTagIds, array $scopeTagMapping): array + { + if ($scopeTagIds === null || $scopeTagMapping === []) { + return $payload; + } + + $payload['roleScopeTagIds'] = array_values($scopeTagIds); + + return $payload; + } + + /** + * @param array> $entries + */ + private function auditFoundationMapping( + Tenant $tenant, + RestoreRun $restoreRun, + array $entries, + ?string $actorEmail, + ?string $actorName + ): void { + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $decision = $entry['decision'] ?? 'mapped_existing'; + $action = match ($decision) { + 'created_copy' => 'restore.foundation.created_copy', + 'created' => 'restore.foundation.created', + 'failed' => 'restore.foundation.failed', + 'skipped' => 'restore.foundation.skipped', + default => 'restore.foundation.mapped', + }; + + $status = match ($decision) { + 'failed' => 'failed', + 'skipped' => 'warning', + default => 'success', + }; + + $this->auditLogger->log( + tenant: $tenant, + action: $action, + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'type' => $entry['type'] ?? null, + 'source_id' => $entry['sourceId'] ?? null, + 'source_name' => $entry['sourceName'] ?? null, + 'target_id' => $entry['targetId'] ?? null, + 'target_name' => $entry['targetName'] ?? null, + 'decision' => $decision, + 'reason' => $entry['reason'] ?? null, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: $status + ); + } + } + /** * @param array|null $selectedItemIds */ diff --git a/app/Services/Intune/TenantPermissionService.php b/app/Services/Intune/TenantPermissionService.php index 65eb18b..415c3c3 100644 --- a/app/Services/Intune/TenantPermissionService.php +++ b/app/Services/Intune/TenantPermissionService.php @@ -39,10 +39,16 @@ public function getGrantedPermissions(Tenant $tenant): array * @param array|null}|string>|null $grantedStatuses * @param bool $persist Persist comparison results to tenant_permissions * @param bool $liveCheck If true, fetch actual permissions from Graph API + * @param bool $useConfiguredStub Include configured stub permissions when no live check is used * @return array{overall_status:string,permissions:array,status:string,details:array|null}>} */ - public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $persist = true, bool $liveCheck = false): array - { + public function compare( + Tenant $tenant, + ?array $grantedStatuses = null, + bool $persist = true, + bool $liveCheck = false, + bool $useConfiguredStub = true + ): array { $required = $this->getRequiredPermissions(); $liveCheckFailed = false; $liveCheckDetails = null; @@ -58,8 +64,17 @@ public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $pe } } + $storedStatuses = $this->getGrantedPermissions($tenant); + + if (! $useConfiguredStub) { + $storedStatuses = $this->dropConfiguredStatuses($storedStatuses); + } + $granted = $this->normalizeGrantedStatuses( - $grantedStatuses ?? array_replace_recursive($this->configuredGrantedStatuses(), $this->getGrantedPermissions($tenant)) + $grantedStatuses ?? array_replace_recursive( + $useConfiguredStub && ! $liveCheck ? $this->configuredGrantedStatuses() : [], + $storedStatuses + ) ); $results = []; $hasMissing = false; @@ -138,6 +153,23 @@ private function normalizeGrantedStatuses(array $granted): array return $normalized; } + /** + * @param array|null,last_checked_at:?\Illuminate\Support\Carbon}> $granted + * @return array|null,last_checked_at:?\Illuminate\Support\Carbon}> + */ + private function dropConfiguredStatuses(array $granted): array + { + foreach ($granted as $key => $value) { + $source = $value['details']['source'] ?? null; + + if ($source === 'configured') { + unset($granted[$key]); + } + } + + return $granted; + } + /** * @return array|null}> */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index e77eff5..bdf58d4 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -197,5 +197,56 @@ 'id_field' => 'id', 'hydration' => 'properties', ], + 'assignmentFilter' => [ + 'resource' => 'deviceManagement/assignmentFilters', + 'allowed_select' => ['id', 'displayName', 'description', 'platform', 'rule', '@odata.type', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceAndAppManagementAssignmentFilter', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'isBuiltIn', + 'createdDateTime', + 'lastModifiedDateTime', + ], + ], + 'roleScopeTag' => [ + 'resource' => 'deviceManagement/roleScopeTags', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'isBuiltIn'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.roleScopeTag', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'isBuiltIn', + 'createdDateTime', + 'lastModifiedDateTime', + ], + ], + 'notificationMessageTemplate' => [ + 'resource' => 'deviceManagement/notificationMessageTemplates', + 'allowed_select' => ['id', 'displayName', 'description', 'brandingOptions', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.notificationMessageTemplate', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'localizedNotificationMessages', + 'createdDateTime', + 'lastModifiedDateTime', + ], + ], ], ]; diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 935c368..87523f4 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -62,6 +62,12 @@ 'description' => 'Read Intune RBAC settings including scope tags for backup metadata enrichment.', 'features' => ['scope-tags', 'backup-metadata', 'assignments'], ], + [ + 'key' => 'DeviceManagementRBAC.ReadWrite.All', + 'type' => 'application', + 'description' => 'Manage Intune RBAC scope tags for foundation backup and restore.', + 'features' => ['scope-tags', 'foundations', 'backup', 'restore'], + ], [ 'key' => 'Group.Read.All', 'type' => 'application', @@ -94,7 +100,7 @@ 'Directory.Read.All', 'User.Read', 'DeviceManagementScripts.ReadWrite.All', - + // Feature 004 - Assignments & Scope Tags (granted seit 2025-12-22): 'DeviceManagementRBAC.Read.All', // Scope Tag Namen auflösen 'Group.Read.All', // Group Namen für Assignments auflösen diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 6de7643..fe389b8 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -115,6 +115,39 @@ ], ], + 'foundation_types' => [ + [ + 'type' => 'assignmentFilter', + 'label' => 'Assignment Filter', + 'category' => 'Foundations', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/assignmentFilters', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'low', + ], + [ + 'type' => 'roleScopeTag', + 'label' => 'Scope Tag', + 'category' => 'Foundations', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/roleScopeTags', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'low', + ], + [ + 'type' => 'notificationMessageTemplate', + 'label' => 'Notification Message Template', + 'category' => 'Foundations', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/notificationMessageTemplates', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'low', + ], + ], + 'features' => [ 'conditional_access' => true, ], diff --git a/resources/views/filament/infolists/entries/restore-preview.blade.php b/resources/views/filament/infolists/entries/restore-preview.blade.php index 2138c31..00c852e 100644 --- a/resources/views/filament/infolists/entries/restore-preview.blade.php +++ b/resources/views/filament/infolists/entries/restore-preview.blade.php @@ -1,29 +1,90 @@ @php $preview = $getState() ?? []; + $foundationItems = collect($preview)->filter(function ($item) { + return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); + }); + $policyItems = collect($preview)->reject(function ($item) { + return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); + }); @endphp @if (empty($preview))

No preview available.

@else -
- @foreach ($preview as $item) -
-
- {{ $item['policy_identifier'] ?? 'Policy' }} - - {{ $item['action'] ?? 'action' }} - -
-
- {{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }} -
- - @if (! empty($item['validation_warning'])) -
- {{ $item['validation_warning'] }} +
+ @if ($foundationItems->isNotEmpty()) +
+
Foundations
+ @foreach ($foundationItems as $item) + @php + $decision = $item['decision'] ?? 'mapped_existing'; + $decisionColor = match ($decision) { + 'created' => 'text-green-700 bg-green-100 border-green-200', + 'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200', + 'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200', + 'failed' => 'text-red-700 bg-red-100 border-red-200', + 'skipped' => 'text-amber-900 bg-amber-50 border-amber-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + @endphp +
+
+ {{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }} + + {{ $decision }} + +
+
+ {{ $item['type'] ?? 'foundation' }} +
+ @if (! empty($item['targetName'])) +
+ Target: {{ $item['targetName'] }} +
+ @endif + @if (! empty($item['reason'])) +
+ {{ $item['reason'] }} +
+ @endif
- @endif + @endforeach
- @endforeach + @endif + + @if ($policyItems->isNotEmpty()) +
+
Policies
+ @foreach ($policyItems as $item) + @php + $restoreMode = $item['restore_mode'] ?? null; + @endphp +
+
+ {{ $item['policy_identifier'] ?? 'Policy' }} +
+ @if ($restoreMode === 'preview-only') + + preview-only + + @endif + + {{ $item['action'] ?? 'action' }} + +
+
+
+ {{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }} +
+ + @if (! empty($item['validation_warning'])) +
+ {{ $item['validation_warning'] }} +
+ @endif +
+ @endforeach +
+ @endif
@endif diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 8c429e3..7a41afb 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -1,243 +1,308 @@ @php $results = $getState() ?? []; + $foundationItems = collect($results)->filter(function ($item) { + return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); + }); + $policyItems = collect($results)->reject(function ($item) { + return is_array($item) && array_key_exists('decision', $item) && array_key_exists('sourceId', $item); + }); @endphp @if (empty($results))

No results recorded.

@else @php - $needsAttention = collect($results)->contains(function ($item) { + $needsAttention = $policyItems->contains(function ($item) { $status = $item['status'] ?? null; return in_array($status, ['partial', 'manual_required'], true); }); @endphp -
+
+ @if ($foundationItems->isNotEmpty()) +
+
Foundations
+ @foreach ($foundationItems as $item) + @php + $decision = $item['decision'] ?? 'mapped_existing'; + $decisionColor = match ($decision) { + 'created' => 'text-green-700 bg-green-100 border-green-200', + 'created_copy' => 'text-amber-900 bg-amber-100 border-amber-200', + 'mapped_existing' => 'text-blue-700 bg-blue-100 border-blue-200', + 'failed' => 'text-red-700 bg-red-100 border-red-200', + 'skipped' => 'text-amber-900 bg-amber-50 border-amber-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + @endphp +
+
+ {{ $item['sourceName'] ?? $item['sourceId'] ?? 'Foundation' }} + + {{ $decision }} + +
+
+ {{ $item['type'] ?? 'foundation' }} +
+ @if (! empty($item['targetName'])) +
+ Target: {{ $item['targetName'] }} +
+ @endif + @if (! empty($item['reason'])) +
+ {{ $item['reason'] }} +
+ @endif +
+ @endforeach +
+ @endif + @if ($needsAttention)
Some settings could not be applied automatically. Review the per-setting details below.
@endif - @foreach ($results as $item) -
-
-
- {{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }} - {{ $item['policy_type'] ?? '' }} -
- @php - $status = $item['status'] ?? 'unknown'; - $statusColor = match ($status) { - 'applied' => 'text-green-700 bg-green-100 border-green-200', - 'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200', - 'partial' => 'text-amber-900 bg-amber-50 border-amber-200', - 'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200', - 'failed' => 'text-red-700 bg-red-100 border-red-200', - default => 'text-gray-700 bg-gray-100 border-gray-200', - }; - @endphp - - {{ $status }} - -
- @php - $itemReason = $item['reason'] ?? null; - $itemGraphMessage = $item['graph_error_message'] ?? null; - @endphp - - @if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason)) -
- {{ $itemReason }} -
- @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 - -
- Assignments: {{ (int) ($summary['success'] ?? 0) }} success • - {{ (int) ($summary['failed'] ?? 0) }} failed • - {{ (int) ($summary['skipped'] ?? 0) }} skipped -
- - @if ($assignmentIssues->isNotEmpty()) -
- Assignment details -
- @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 - -
-
-
- Assignment {{ $assignmentGroupId ?? 'unknown group' }} -
- - {{ $outcomeStatus }} - -
- - @if (! empty($outcome['mapped_group_id'])) -
- Mapped to: {{ $outcome['mapped_group_id'] }} -
- @endif - - @php - $outcomeReason = $outcome['reason'] ?? null; - $outcomeGraphMessage = $outcome['graph_error_message'] ?? null; - @endphp - - @if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason)) -
- {{ $outcomeReason }} -
- @endif - - @if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code'])) -
-
{{ $outcome['graph_error_message'] ?? 'Unknown error' }}
- @if (! empty($outcome['graph_error_code'])) -
Code: {{ $outcome['graph_error_code'] }}
- @endif -
- @endif -
- @endforeach + @if ($policyItems->isNotEmpty()) +
+
Policies
+ @foreach ($policyItems as $item) +
+
+
+ {{ $item['policy_identifier'] ?? $item['policy_id'] ?? 'Policy' }} + {{ $item['policy_type'] ?? '' }}
-
- @endif - @endif + @php + $status = $item['status'] ?? 'unknown'; + $restoreMode = $item['restore_mode'] ?? null; + $statusColor = match ($status) { + 'applied' => 'text-green-700 bg-green-100 border-green-200', + 'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200', + 'skipped' => 'text-amber-900 bg-amber-50 border-amber-200', + 'partial' => 'text-amber-900 bg-amber-50 border-amber-200', + 'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200', + 'failed' => 'text-red-700 bg-red-100 border-red-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + @endphp +
+ @if ($restoreMode === 'preview-only') + + preview-only + + @endif + + {{ $status }} + +
+
- @if (! empty($item['created_policy_id'])) - @php - $createdMode = $item['created_policy_mode'] ?? null; - $createdMessage = $createdMode === 'metadata_only' - ? 'New policy created (metadata only). Apply settings manually.' - : 'New policy created (manual cleanup required).'; - @endphp -
- {{ $createdMessage }} ID: {{ $item['created_policy_id'] }} -
- @endif + @php + $itemReason = $item['reason'] ?? null; + $itemGraphMessage = $item['graph_error_message'] ?? null; - @if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code'])) -
-
Graph error
-
{{ $item['graph_error_message'] ?? 'Unknown error' }}
- @if (! empty($item['graph_error_code'])) -
Code: {{ $item['graph_error_code'] }}
+ if ($itemReason === 'preview_only') { + $itemReason = 'Preview-only policy type; execution skipped.'; + } + @endphp + + @if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason)) +
+ {{ $itemReason }} +
@endif - @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) -
- Details -
- @if (! empty($item['graph_request_id'])) -
request-id: {{ $item['graph_request_id'] }}
- @endif - @if (! empty($item['graph_client_request_id'])) -
client-request-id: {{ $item['graph_client_request_id'] }}
- @endif -
-
- @endif -
- @endif - @if (! empty($item['settings_apply']) && is_array($item['settings_apply'])) - @php - $apply = $item['settings_apply']; - $total = (int) ($apply['total'] ?? 0); - $applied = (int) ($apply['applied'] ?? 0); - $failed = (int) ($apply['failed'] ?? 0); - $manual = (int) ($apply['manual_required'] ?? 0); - $issues = $apply['issues'] ?? []; - @endphp + @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 -
- Settings applied: {{ $applied }}/{{ $total }} - @if ($failed > 0 || $manual > 0) - • {{ $failed }} failed • {{ $manual }} manual - @endif -
+
+ Assignments: {{ (int) ($summary['success'] ?? 0) }} success • + {{ (int) ($summary['failed'] ?? 0) }} failed • + {{ (int) ($summary['skipped'] ?? 0) }} skipped +
- @if (! empty($issues)) -
- Settings requiring attention -
- @foreach ($issues as $issue) - @php - $issueStatus = $issue['status'] ?? 'unknown'; - $issueColor = match ($issueStatus) { - 'failed' => 'text-red-700 bg-red-100 border-red-200', - 'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200', - default => 'text-gray-700 bg-gray-100 border-gray-200', - }; - @endphp -
-
-
- Setting {{ $issue['setting_id'] ?? 'unknown' }} -
- - {{ $issueStatus }} - -
+ @if ($assignmentIssues->isNotEmpty()) +
+ Assignment details +
+ @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 - @if (! empty($issue['reason'])) -
- {{ $issue['reason'] }} -
- @endif +
+
+
+ Assignment {{ $assignmentGroupId ?? 'unknown group' }} +
+ + {{ $outcomeStatus }} + +
- @if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code'])) -
-
{{ $issue['graph_error_message'] ?? 'Unknown error' }}
- @if (! empty($issue['graph_error_code'])) -
Code: {{ $issue['graph_error_code'] }}
+ @if (! empty($outcome['mapped_group_id'])) +
+ Mapped to: {{ $outcome['mapped_group_id'] }} +
@endif - @if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id'])) -
- @if (! empty($issue['graph_request_id'])) -
request-id: {{ $issue['graph_request_id'] }}
- @endif - @if (! empty($issue['graph_client_request_id'])) -
client-request-id: {{ $issue['graph_client_request_id'] }}
+ + @php + $outcomeReason = $outcome['reason'] ?? null; + $outcomeGraphMessage = $outcome['graph_error_message'] ?? null; + @endphp + + @if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason)) +
+ {{ $outcomeReason }} +
+ @endif + + @if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code'])) +
+
{{ $outcome['graph_error_message'] ?? 'Unknown error' }}
+ @if (! empty($outcome['graph_error_code'])) +
Code: {{ $outcome['graph_error_code'] }}
@endif
@endif
- @endif + @endforeach
- @endforeach -
-
- @endif - @endif +
+ @endif + @endif - @if (! empty($item['platform'])) -
- Platform: {{ $item['platform'] }} + @if (! empty($item['created_policy_id'])) + @php + $createdMode = $item['created_policy_mode'] ?? null; + $createdMessage = $createdMode === 'metadata_only' + ? 'New policy created (metadata only). Apply settings manually.' + : 'New policy created (manual cleanup required).'; + @endphp +
+ {{ $createdMessage }} ID: {{ $item['created_policy_id'] }} +
+ @endif + + @if (! empty($item['graph_error_message']) || ! empty($item['graph_error_code'])) +
+
Graph error
+
{{ $item['graph_error_message'] ?? 'Unknown error' }}
+ @if (! empty($item['graph_error_code'])) +
Code: {{ $item['graph_error_code'] }}
+ @endif + @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) +
+ Details +
+ @if (! empty($item['graph_request_id'])) +
request-id: {{ $item['graph_request_id'] }}
+ @endif + @if (! empty($item['graph_client_request_id'])) +
client-request-id: {{ $item['graph_client_request_id'] }}
+ @endif +
+
+ @endif +
+ @endif + + @if (! empty($item['settings_apply']) && is_array($item['settings_apply'])) + @php + $apply = $item['settings_apply']; + $total = (int) ($apply['total'] ?? 0); + $applied = (int) ($apply['applied'] ?? 0); + $failed = (int) ($apply['failed'] ?? 0); + $manual = (int) ($apply['manual_required'] ?? 0); + $issues = $apply['issues'] ?? []; + @endphp + +
+ Settings applied: {{ $applied }}/{{ $total }} + @if ($failed > 0 || $manual > 0) + • {{ $failed }} failed • {{ $manual }} manual + @endif +
+ + @if (! empty($issues)) +
+ Settings requiring attention +
+ @foreach ($issues as $issue) + @php + $issueStatus = $issue['status'] ?? 'unknown'; + $issueColor = match ($issueStatus) { + 'failed' => 'text-red-700 bg-red-100 border-red-200', + 'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + @endphp +
+
+
+ Setting {{ $issue['setting_id'] ?? 'unknown' }} +
+ + {{ $issueStatus }} + +
+ + @if (! empty($issue['reason'])) +
+ {{ $issue['reason'] }} +
+ @endif + + @if (! empty($issue['graph_error_message']) || ! empty($issue['graph_error_code'])) +
+
{{ $issue['graph_error_message'] ?? 'Unknown error' }}
+ @if (! empty($issue['graph_error_code'])) +
Code: {{ $issue['graph_error_code'] }}
+ @endif + @if (! empty($issue['graph_request_id']) || ! empty($issue['graph_client_request_id'])) +
+ @if (! empty($issue['graph_request_id'])) +
request-id: {{ $issue['graph_request_id'] }}
+ @endif + @if (! empty($issue['graph_client_request_id'])) +
client-request-id: {{ $issue['graph_client_request_id'] }}
+ @endif +
+ @endif +
+ @endif +
+ @endforeach +
+
+ @endif + @endif + + @if (! empty($item['platform'])) +
+ Platform: {{ $item['platform'] }} +
+ @endif
- @endif + @endforeach
- @endforeach + @endif
@endif diff --git a/specs/006-sot-foundations-assignments/checklists/requirements.md b/specs/006-sot-foundations-assignments/checklists/requirements.md new file mode 100644 index 0000000..3f7b940 --- /dev/null +++ b/specs/006-sot-foundations-assignments/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: SoT Foundations & Assignments + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-12-25 +**Feature**: [specs/006-sot-foundations-assignments/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan` + +- Validation pass: Spec contains no [NEEDS CLARIFICATION] markers and scopes CA restore to preview-only until dependency mapping exists. diff --git a/specs/006-sot-foundations-assignments/contracts/assignment-apply-request.schema.json b/specs/006-sot-foundations-assignments/contracts/assignment-apply-request.schema.json new file mode 100644 index 0000000..0ab92a7 --- /dev/null +++ b/specs/006-sot-foundations-assignments/contracts/assignment-apply-request.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "assignment-apply-request.schema.json", + "title": "AssignmentApplyRequest", + "type": "object", + "required": ["assignments"], + "properties": { + "assignments": { + "type": "array", + "items": { + "type": "object", + "required": ["target"], + "properties": { + "target": { + "type": "object", + "required": ["@odata.type"], + "properties": { + "@odata.type": { "type": "string" }, + "groupId": { "type": "string" }, + "deviceAndAppManagementAssignmentFilterId": { "type": "string" }, + "deviceAndAppManagementAssignmentFilterType": { + "type": "string", + "enum": ["include", "exclude", "Include", "Exclude"] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true +} diff --git a/specs/006-sot-foundations-assignments/contracts/foundation-snapshot.schema.json b/specs/006-sot-foundations-assignments/contracts/foundation-snapshot.schema.json new file mode 100644 index 0000000..0e2a90d --- /dev/null +++ b/specs/006-sot-foundations-assignments/contracts/foundation-snapshot.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "foundation-snapshot.schema.json", + "title": "FoundationSnapshot", + "type": "object", + "required": ["type", "sourceId", "payload"], + "properties": { + "type": { + "type": "string", + "enum": ["assignmentFilter", "roleScopeTag", "notificationMessageTemplate"] + }, + "sourceId": { "type": "string" }, + "displayName": { "type": "string" }, + "payload": { "type": "object" }, + "metadata": { "type": ["object", "null"] } + }, + "additionalProperties": false +} diff --git a/specs/006-sot-foundations-assignments/contracts/restore-mapping-report.schema.json b/specs/006-sot-foundations-assignments/contracts/restore-mapping-report.schema.json new file mode 100644 index 0000000..c9ba3f3 --- /dev/null +++ b/specs/006-sot-foundations-assignments/contracts/restore-mapping-report.schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "restore-mapping-report.schema.json", + "title": "RestoreMappingReport", + "type": "object", + "required": ["foundations"], + "properties": { + "foundations": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "sourceId", "decision"], + "properties": { + "type": { + "type": "string", + "enum": ["assignmentFilter", "roleScopeTag", "notificationMessageTemplate"] + }, + "sourceId": { "type": "string" }, + "sourceName": { "type": "string" }, + "decision": { + "type": "string", + "enum": ["mapped_existing", "created", "created_copy", "skipped", "failed"] + }, + "targetId": { "type": ["string", "null"] }, + "targetName": { "type": ["string", "null"] }, + "reason": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": true +} diff --git a/specs/006-sot-foundations-assignments/data-model.md b/specs/006-sot-foundations-assignments/data-model.md new file mode 100644 index 0000000..d9927c5 --- /dev/null +++ b/specs/006-sot-foundations-assignments/data-model.md @@ -0,0 +1,111 @@ +# Data Model: SoT Foundations & Assignments (006) + +This feature reuses existing snapshot and restore run entities, and introduces a consistent JSON “mapping + decisions” report. + +## Existing Entities (today) + +### BackupSet + +- Purpose: Groups a point-in-time capture for a tenant. +- Relationships: hasMany `BackupItem`. + +### BackupItem + +- Purpose: Stores an immutable snapshot item. +- Key fields (relevant): + - `tenant_id`, `backup_set_id` + - `policy_id` (nullable) + - `policy_identifier` (Graph id) + - `policy_type` (logical type) + - `payload` (raw JSON) + - `metadata` (normalized JSON) + +### RestoreRun + +- Purpose: Tracks restore preview/execution lifecycle. +- Key fields (relevant): + - `is_dry_run` + - `requested_items` (selection) + - `preview` (dry-run decision report) + - `results` (execution report) + - `metadata` (extra structured info) + +## New / Extended Concepts (this feature) + +### FoundationSnapshot (logical concept) + +Represented as a `backup_items` row. + +- `policy_type` (new keys): + - `assignmentFilter` + - `roleScopeTag` + - `notificationMessageTemplate` +- `policy_identifier`: source Graph `id` +- `policy_id`: `null` +- `payload`: raw Graph resource JSON +- `metadata` (proposed, shape): + + ```json + { + "displayName": "...", + "kind": "assignmentFilter|roleScopeTag|notificationMessageTemplate", + "graph": { + "resource": "deviceManagement/assignmentFilters", + "apiVersion": "v1.0" + } + } + ``` + +### RestoreMappingReport (logical concept) + +Stored within `restore_runs.preview`/`restore_runs.results`. + +- `mappings.foundations[]` (proposed shape): + + ```json + { + "type": "assignmentFilter", + "sourceId": "", + "sourceName": "Filter A", + "decision": "mapped_existing|created|created_copy|failed", + "targetId": "", + "targetName": "Filter A (Copy)", + "reason": "..." + } + ``` + +### AssignmentDecisionReport (logical concept) + +Stored within `restore_runs.preview`/`restore_runs.results`. + +- `assignments[]` entries (proposed shape): + + ```json + { + "policyType": "settingsCatalogPolicy", + "sourcePolicyId": "...", + "targetPolicyId": "...", + "decision": "applied|skipped|failed", + "reason": "missing_filter_mapping|missing_group_mapping|preview_only|graph_error", + "details": { + "sourceAssignmentCount": 3, + "appliedAssignmentCount": 2 + } + } + ``` + +## Relationships / Flow + +- `BackupSet` contains both “policy snapshots” and “foundation snapshots” as `BackupItem` rows. +- `RestoreRun` consumes a `BackupSet` and produces: + - foundation mapping report + - policy restore decisions + - assignment application decisions + +## Validation & State Transitions + +- Restore execution is single-writer per tenant (existing safety requirement FR-009). +- Restore behavior: + - Preview (`is_dry_run=true`): builds mapping/decisions, **no Graph writes**. + - Execute (`is_dry_run=false`): creates missing foundations, restores policies, applies assignments when safe. + - Conditional Access entries are always recorded as preview-only/skipped in execute. diff --git a/specs/006-sot-foundations-assignments/plan.md b/specs/006-sot-foundations-assignments/plan.md new file mode 100644 index 0000000..be28388 --- /dev/null +++ b/specs/006-sot-foundations-assignments/plan.md @@ -0,0 +1,108 @@ +# Implementation Plan: SoT Foundations & Assignments + +**Branch**: `006-sot-foundations-assignments` | **Date**: 2025-12-25 | **Spec**: ./spec.md +**Input**: Feature specification from `/specs/006-sot-foundations-assignments/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow. + +## Summary + +Implement foundations-first backup/restore for Intune dependencies (Assignment Filters, Scope Tags, Notification Message Templates) and extend restore to be assignment-aware using a deterministic old→new ID mapping report. Conditional Access remains preview-only (never executed) until its dependency mapping is supported. + +Phase outputs: +- Phase 0 research: `./research.md` +- Phase 1 design: `./data-model.md`, `./contracts/`, `./quickstart.md` + +## Technical Context + + + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3, Microsoft Graph (custom client abstraction) +**Storage**: PostgreSQL (JSONB payload storage for snapshots) +**Testing**: Pest v4 + PHPUnit 12 +**Target Platform**: Docker/Sail locally; container deploy via Dokploy +**Project Type**: Web application (Laravel backend + Filament admin UI) +**Performance Goals**: Restore preview for ~100 items in <2 minutes (SC-003); handle Graph paging and throttling safely +**Constraints**: Restore must be defensive: no deletions; skip unsafe assignments; produce audit/report; respect Graph throttling +**Scale/Scope**: Tenants with large policy inventories; focus on foundational object types + assignment application for already-supported policy types + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The constitution at `.specify/memory/constitution.md` is currently an unfilled template (no ratified gates). For this feature, adopt the repo’s documented operating rules as gates: + +- **Sail-first** local dev/test commands. +- **SpecKit Gate Rule**: code changes must be accompanied by `specs/006-sot-foundations-assignments/` updates. +- **Testing is required**: every behavioral change covered by Pest tests. +- **Safety**: restore never deletes; assignments only applied when mapped; CA stays preview-only. +- **Auditability**: restore/backup outcomes recorded and tenant-scoped. + +If the team later ratifies a real constitution, re-map these gates accordingly. + +**Post-Phase 1 re-check**: Pass (no violations introduced by the Phase 1 design artifacts). + +## Project Structure + +### Documentation (this feature) + +```text +specs/[###-feature]/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) + + +```text +app/ +├── Filament/ +│ └── Resources/ +├── Jobs/ +├── Models/ +│ ├── BackupItem.php +│ ├── BackupSet.php +│ └── RestoreRun.php +├── Services/ +│ ├── Graph/ +│ └── Intune/ +└── Support/ + +config/ +├── graph_contracts.php +└── tenantpilot.php + +database/ +├── migrations/ +└── factories/ + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Implement as incremental additions to existing Laravel services/models/jobs, with Filament UI using the existing Backup/Restore flows. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/006-sot-foundations-assignments/quickstart.md b/specs/006-sot-foundations-assignments/quickstart.md new file mode 100644 index 0000000..8b23863 --- /dev/null +++ b/specs/006-sot-foundations-assignments/quickstart.md @@ -0,0 +1,55 @@ +# Quickstart: SoT Foundations & Assignments (006) + +This is a developer/operator checklist to validate foundations-first restore and assignment-aware restore. + +## Prerequisites + +- Local dev via Sail. +- A tenant configured for Graph access with sufficient permissions for: + - Assignment filters: `DeviceManagementConfiguration.ReadWrite.All` + - Scope tags: `DeviceManagementRBAC.ReadWrite.All` + - Notification templates: `DeviceManagementServiceConfig.ReadWrite.All` + +## Scenario A: Foundations backup + restore + +1. In a test tenant, create: + - 1–2 assignment filters + - 1–2 scope tags (non-built-in) + - 1 notification message template +2. Run a sync + backup via the app’s existing workflow. +3. In the target tenant, ensure those objects do not exist. +4. Run restore in **preview**: + - Verify preview includes a “Foundations” section. + - Verify it reports old→new mapping decisions. +5. Run restore in **execute**: + - Verify missing foundations are created. + - Verify collisions result in “created_copy” behavior (if you intentionally create same-named items beforehand). + +## Scenario B: Assignment-aware restore + +1. Create a policy that has assignments: + - Group targeting + - Assignment filters (include/exclude) + - Scope tags where applicable +2. Back up the tenant. +3. Restore into a target tenant where: + - some foundations exist + - some foundations are missing +4. Run restore preview: + - Verify assignments are marked “applied” only when mappings exist. + - Verify unsafe assignments are “skipped” with explicit reasons (no broad targeting). +5. Run restore execute: + - Verify the policy is restored. + - Verify assignment application uses the mapping. + +## Scenario C: Conditional Access preview-only + +1. Ensure the backup contains at least one Conditional Access policy. +2. Run restore preview: + - Verify CA items appear with a clear preview-only marker. +3. Run restore execute: + - Verify CA changes are not applied and are recorded as skipped/preview-only. + +## Notes + +- If UI changes don’t appear, run the project’s dev/build pipeline (`composer run dev` / `pnpm dev`) according to existing repo conventions. diff --git a/specs/006-sot-foundations-assignments/research.md b/specs/006-sot-foundations-assignments/research.md new file mode 100644 index 0000000..a13d6c5 --- /dev/null +++ b/specs/006-sot-foundations-assignments/research.md @@ -0,0 +1,86 @@ +# Research: SoT Foundations & Assignments (006) + +This document resolves planning unknowns and records decisions for implementing foundations-first backup/restore and assignment-aware restore. + +## Decision: Foundation object endpoints and permissions + +- **Decision**: Implement “foundation” backup/restore for: + - Assignment Filters via `deviceManagement/assignmentFilters` (permission: `DeviceManagementConfiguration.ReadWrite.All`). + - Scope Tags via `deviceManagement/roleScopeTags` (permission: `DeviceManagementRBAC.ReadWrite.All`). + - Notification Message Templates via `deviceManagement/notificationMessageTemplates` (permission: `DeviceManagementServiceConfig.ReadWrite.All`, with `localizedNotificationMessages` treated as a future enhancement). +- **Rationale**: These are explicitly called out as SoT foundations and appear as dependencies in the IntuneManagement reference implementation. +- **Alternatives considered**: + - Treat foundations as “manual prerequisites” only (no backup/restore) → rejected because it blocks safe assignment restore. + - Store only names (no full payload) → rejected because restore needs full object definitions. + +## Decision: Assignment apply mechanism (Graph) + +- **Decision**: Apply assignments using a per-resource `.../{id}/assign` Graph action (default), with request body shape: + + ```json + { + "assignments": [ + { + "target": { + "@odata.type": "...", + "groupId": "...", + "deviceAndAppManagementAssignmentFilterId": "...", + "deviceAndAppManagementAssignmentFilterType": "Include|Exclude" + } + } + ] + } + ``` + + and support type-specific overrides if needed. +- **Rationale**: Matches the IntuneManagement import approach and aligns with SoT “apply assignments after foundations exist”. +- **Alternatives considered**: + - PATCH the resource with an `assignments` property → rejected because many Intune resources do not support assignment updates via PATCH. + - Only restore object payloads, never assignments → rejected (SoT requires assignment-aware restore). + +## Decision: Mapping strategy (deterministic, safe) + +- **Decision**: Produce and persist an “old → new” mapping for foundation objects by matching primarily on `displayName` (or name-equivalent), with collision handling: + - If a unique match exists in the target tenant by name: reuse (map old → existing). + - If no match exists: create (map old → created). + - If multiple matches exist: create a copy with a predictable suffix and record “created_copy” in the report. +- **Rationale**: SoT requires determinism and auditability; mapping by opaque IDs is impossible across tenants. +- **Alternatives considered**: + - Always create new objects regardless of matches → rejected due to duplication and name collision risk. + - Hash-based matching (normalize and compare multiple fields) → deferred; start with name-based plus explicit collision handling. + +## Decision: Where to store mappings and restore decision report + +- **Decision**: Store mapping + decisions in `restore_runs.preview` (dry-run) and `restore_runs.results` (execute), optionally mirrored into `restore_runs.metadata` for fast access. +- **Rationale**: The schema already supports JSON `preview`/`results`; this keeps the first iteration simple and audit-friendly. +- **Alternatives considered**: + - Dedicated `restore_mappings` table → deferred until querying/reporting requirements demand it. + +## Decision: How to represent foundation snapshots in storage + +- **Decision**: Store foundation snapshots as `backup_items` rows with: + - `policy_id = null` + - `policy_type` set to a dedicated type key (e.g. `assignmentFilter`, `roleScopeTag`, `notificationMessageTemplate`) + - `policy_identifier` set to the Graph object `id` + - `payload` containing the raw Graph resource + - `metadata` containing normalized identifiers used for matching (e.g. `displayName`). +- **Rationale**: `backup_items.policy_id` is nullable; reusing the same snapshot container avoids schema churn. +- **Alternatives considered**: + - New “foundation_snapshots” table → rejected for MVP due to extra migrations and duplication. + +## Decision: Conditional Access restore behavior (safety) + +- **Decision**: Keep Conditional Access restore as **preview-only**, even in execute mode. +- **Rationale**: CA depends on identity objects (e.g., named locations) and is security-critical; SoT explicitly allows preview-first for risky items. +- **Alternatives considered**: + - Allow CA restore with best-effort group/user mapping → rejected as too risky without complete dependency mapping. + +## Decision: Scope for assignment-aware restore (initial) + +- **Decision**: Apply assignment mapping for existing supported configuration objects (policy types already in `config/tenantpilot.php`), focusing first on targets that include: + - group targeting (`groupId`) + - assignment filters (`deviceAndAppManagementAssignmentFilterId`/Type) + - role scope tags (`roleScopeTagIds`) where applicable. +- **Rationale**: Incrementally delivers value without requiring support for all object classes in SoT. +- **Alternatives considered**: + - Expand to named locations / terms of use / authentication strengths immediately → deferred (separate dependency set). diff --git a/specs/006-sot-foundations-assignments/spec.md b/specs/006-sot-foundations-assignments/spec.md new file mode 100644 index 0000000..a65bcd9 --- /dev/null +++ b/specs/006-sot-foundations-assignments/spec.md @@ -0,0 +1,92 @@ +# Feature Specification: SoT Foundations & Assignments + +**Feature Branch**: `006-sot-foundations-assignments` +**Created**: 2025-12-25 +**Status**: Draft +**Input**: User description: "SoT Foundations & Assignments: implement backup/restore foundations (assignment filters, scope tags, notification templates) and add assignment-aware backup/restore pipeline with ID mapping for core Intune objects; keep Conditional Access restore preview-only until named locations/mapping exist." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Restore Foundations First (Priority: P1) + +As an admin, I want to back up and restore the core "foundation" objects that other configurations depend on (assignment filters, scope tags, and compliance notification templates), so that later restores can reliably re-apply assignments and dependencies. + +**Why this priority**: Without these foundations, restores either fail or must skip assignments/dependencies, which reduces trust and makes outcomes unpredictable. + +**Independent Test**: In a test tenant with at least one filter, one scope tag, and one notification template: create a backup snapshot, then restore into a tenant where they are missing. Verify that the restored objects exist and that a mapping from old IDs to new IDs is produced. + +**Acceptance Scenarios**: + +1. **Given** a tenant with assignment filters, **When** a backup is created and later restored into a tenant missing those filters, **Then** missing filters are created and the restore reports the old→new identifier mapping. +2. **Given** a tenant with scope tags, **When** a restore runs, **Then** scope tags are restored before any dependent objects are applied. +3. **Given** a tenant with compliance notification templates, **When** a restore runs, **Then** templates are restored before applying compliance policy scheduled actions. + +--- + +### User Story 2 - Apply Assignments Safely (Priority: P2) + +As an admin, I want restores to apply assignments for supported configuration objects using the foundation mappings, so that a restore reproduces intended targeting while staying safe and auditable. + +**Why this priority**: Restoring payloads without assignments is incomplete; restoring assignments without safe mapping can be dangerous. + +**Independent Test**: Restore a small set of supported configurations that include assignments with filters and scope tags. Verify that assignments are applied when mappings exist, and skipped with a clear reason when mappings are missing. + +**Acceptance Scenarios**: + +1. **Given** a configuration object whose assignments reference filters/scope tags that exist (or can be mapped), **When** restore executes, **Then** assignments are applied and reported as applied. +2. **Given** a configuration object whose assignments reference a missing dependency (e.g., an unknown filter), **When** restore executes, **Then** the assignment is skipped (not broadly applied) and a human-readable reason is recorded. +3. **Given** an object restore with name collisions, **When** the system cannot unambiguously match a target, **Then** it creates a copy with a predictable suffix and records this decision in the restore report. + +--- + +### User Story 3 - Conditional Access Stays Preview-Only (Priority: P3) + +As an admin, I want to preview Conditional Access (CA) policies and their dependencies, but I do not want CA restore to execute automatically until dependency mapping is supported. + +**Why this priority**: CA is security-critical and often depends on other objects (like named locations) and identity references. A preview still delivers value without risking outages. + +**Independent Test**: Include CA policies in a backup and run restore in "preview" mode. Verify preview shows intended actions and highlights missing dependencies, while execute mode does not apply CA changes. + +**Acceptance Scenarios**: + +1. **Given** a backup containing CA policies, **When** a restore preview is generated, **Then** CA items appear in preview with a clear "preview-only" indicator. +2. **Given** a restore execution (non-dry-run), **When** CA items are included, **Then** the system does not apply CA changes and records them as preview-only/skipped. + +### Edge Cases + +- Missing permissions: backup/restore continues for other object types and clearly reports which categories failed due to permissions. +- Name collisions: multiple objects share the same display name; system must avoid ambiguous updates. +- Missing identity references: group/user references cannot be resolved; system must skip the assignment and report. +- Large tenants: operations must cope with pagination and partial failures without losing auditability. +- Throttling/transient failures: system retries safely and produces a final report if some items could not be processed. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST support backup and restore of foundation objects: assignment filters, scope tags, and compliance notification templates. +- **FR-002**: System MUST restore foundation objects before applying any dependent configurations. +- **FR-003**: System MUST produce an identifier mapping report (old→new) for restored foundation objects. +- **FR-004**: System MUST apply assignments for supported configurations using the identifier mapping. +- **FR-005**: System MUST skip assignments that cannot be safely mapped (e.g., missing dependencies) and MUST record a clear skip reason. +- **FR-006**: System MUST be able to run in preview mode that produces the same decision report as execute mode, without making changes. +- **FR-007**: System MUST NOT delete objects in the target tenant as part of restore. +- **FR-008**: System MUST record an audit trail for backup and restore actions, including outcomes, partial failures, and skipped items. +- **FR-009**: System MUST prevent conflicting simultaneous restore executions for the same tenant (single-writer safety). +- **FR-010**: System MUST keep Conditional Access restore as preview-only until dependency mapping for CA is supported. + +### Key Entities *(include if feature involves data)* + +- **Foundation Object Snapshot**: A captured representation of an assignment filter, scope tag, or notification template. +- **Assignment Snapshot**: Captured targeting rules associated with a configuration object. +- **Restore Mapping**: A mapping of source identifiers to newly created target identifiers. +- **Restore Report**: A structured outcome summary containing applied items, skipped items, reasons, and any created copies. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: In a tenant with at least 10 foundation objects, a full foundations restore completes with ≥ 99% of items either applied or explicitly skipped with a reason. +- **SC-002**: For supported configuration objects with assignments, ≥ 95% of assignments are either applied correctly or skipped with a clear reason (no silent failures). +- **SC-003**: Restore preview generation for 100 selected items completes in under 2 minutes in a typical admin environment. +- **SC-004**: Admins can complete a restore workflow (preview → execute) with no ambiguous outcomes: every selected item ends in Applied / Created Copy / Skipped / Failed with a recorded reason. diff --git a/specs/006-sot-foundations-assignments/tasks.md b/specs/006-sot-foundations-assignments/tasks.md new file mode 100644 index 0000000..3540826 --- /dev/null +++ b/specs/006-sot-foundations-assignments/tasks.md @@ -0,0 +1,98 @@ +# Tasks: SoT Foundations & Assignments (006) + +**Branch**: `feat/006-sot-foundations-assignments` | **Date**: 2025-12-25 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md), [data-model.md](./data-model.md), [research.md](./research.md), [contracts](./contracts/) + +## Task Format + +- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete +- **Task ID**: Sequential T001, T002, T003... +- **[P] marker**: Task can run in parallel (different files, no blocking dependencies) +- **[Story] label**: User story tag (US1, US2, US3...) +- **File path**: Always include exact file path in description + +## Phase 1: Foundation Registry and Permissions + +**Purpose**: Define foundation object types and ensure Graph contracts and permissions exist. + +- [x] T001 [P] Add foundation type registry in `config/tenantpilot.php` (assignmentFilter, roleScopeTag, notificationMessageTemplate) with label/category/backup/restore/risk metadata. +- [x] T002 [P] Extend `config/graph_contracts.php` with foundation contracts (resource, create/update methods, id_field, allowed_select, type_family). +- [x] T003 [P] Extend `config/intune_permissions.php` to include foundation permissions (DeviceManagementRBAC.ReadWrite.All and any missing read/write scopes for filters/templates). +- [x] T004 Update type metadata helpers to include foundation types in `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`, and `app/Filament/Resources/RestoreRunResource.php`. + +**Checkpoint**: Foundation types and permissions defined and discoverable by UI helpers. + +--- + +## Phase 2: Foundations Backup Capture + +**Purpose**: Capture assignment filters, scope tags, and notification templates into backup sets. + +- [x] T005 Create `app/Services/Intune/FoundationSnapshotService.php` to list and fetch foundation objects with Graph paging, normalized metadata, and fail-soft behavior. +- [x] T006 Extend `app/Services/Intune/BackupService.php` to capture foundation snapshots into `backup_items` (policy_id null, policy_type set, policy_identifier = source id, metadata includes displayName). +- [x] T007 Add a UI action/toggle to include foundations when adding to a backup set in `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php`. +- [x] T008 Add foundation display helpers on `app/Models/BackupItem.php` (e.g., isFoundation, foundationDisplayName) and use them in `BackupItemsRelationManager`. + +**Checkpoint**: Foundations can be captured and displayed alongside policy backup items. + +--- + +## Phase 3: Foundations Restore and Mapping + +**Purpose**: Restore foundations first and persist deterministic old to new mappings. + +- [x] T009 Create `app/Services/Intune/FoundationMappingService.php` to match by displayName, handle collisions, and emit report entries matching `contracts/restore-mapping-report.schema.json`. +- [x] T010 Extend `app/Services/Intune/RestoreService.php` to run foundation restore first, build preview mapping (dry-run), and persist mapping results in `restore_runs.preview` and `restore_runs.results`. +- [x] T011 Add audit events for foundation mapping decisions and failures in `app/Services/Intune/RestoreService.php`. +- [x] T012 Render foundation mapping in restore UI views: `resources/views/filament/infolists/entries/restore-preview.blade.php` and `resources/views/filament/infolists/entries/restore-results.blade.php`. + +**Checkpoint**: Restore preview and execute include a foundation mapping section with deterministic decisions. + +--- + +## Phase 4: Assignment-Aware Restore + +**Purpose**: Apply assignments only when foundation mappings exist and record clear skip reasons. + +- [x] T013 Extend `app/Services/AssignmentRestoreService.php` to map assignment filter IDs via the foundation mapping; skip and record reasons when mappings are missing. +- [x] T014 Update `app/Services/Intune/RestoreService.php` to pass foundation mappings into assignment restore and apply scope tag mapping when restoring policies. +- [x] T015 Add mapping context to assignment audit logs in `app/Services/AssignmentRestoreService.php`. + +**Checkpoint**: Assignments are applied safely with explicit skip reasons and audit coverage. + +--- + +## Phase 5: Conditional Access Preview-Only Enforcement + +**Purpose**: Keep CA restore preview-only even in execute mode. + +- [x] T016 Update `app/Services/Intune/RestoreService.php` to prevent CA execution (status skipped, reason preview_only) while keeping preview output. +- [x] T017 Update restore UI to surface CA preview-only status in `resources/views/filament/infolists/entries/restore-preview.blade.php` and `resources/views/filament/infolists/entries/restore-results.blade.php`. + +**Checkpoint**: CA items never execute; preview clearly signals preview-only. + +--- + +## Phase 6: Tests and Verification + +**Purpose**: Ensure all new behavior is covered by Pest tests and formatting is clean. + +- [x] T018 [P] Add unit tests for FoundationMappingService in `tests/Unit/FoundationMappingServiceTest.php`. +- [x] T019 [P] Add unit tests for FoundationSnapshotService in `tests/Unit/FoundationSnapshotServiceTest.php`. +- [x] T020 Add feature tests for foundations backup/restore preview and execute in `tests/Feature/FoundationBackupTest.php`, `tests/Feature/Filament/RestorePreviewTest.php`, `tests/Feature/Filament/RestoreExecutionTest.php`, `tests/Feature/RestoreScopeTagMappingTest.php`. +- [x] T021 Add feature tests for assignment mapping and skip reasons in `tests/Feature/RestoreAssignmentApplicationTest.php`. +- [x] T022 Add feature test for CA preview-only execution behavior in `tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php`. +- [x] T023 Run tests: `./vendor/bin/sail artisan test tests/Unit/FoundationSnapshotServiceTest.php tests/Unit/FoundationMappingServiceTest.php tests/Unit/TenantPermissionServiceTest.php tests/Feature/FoundationBackupTest.php tests/Feature/Filament/RestoreExecutionTest.php tests/Feature/Filament/RestorePreviewTest.php tests/Feature/Filament/RestoreItemSelectionTest.php tests/Feature/RestoreAssignmentApplicationTest.php tests/Feature/RestoreScopeTagMappingTest.php tests/Feature/RestoreRunRerunTest.php` +- [x] T024 Run Pint: `./vendor/bin/pint --dirty` + +--- + +## Phase 7: Admin UX and Safety + +**Purpose**: Improve admin clarity and safe reruns for restore flows. + +- [x] T025 Update tenant permission display to ignore configured stub grants in `app/Services/Intune/TenantPermissionService.php` and `app/Filament/Resources/TenantResource.php`, plus tests in `tests/Unit/TenantPermissionServiceTest.php`. +- [x] T026 Improve restore item selection UX (searchable list, descriptions, hint) in `app/Filament/Resources/RestoreRunResource.php` with coverage in `tests/Feature/Filament/RestoreItemSelectionTest.php`. +- [x] T027 Add restore run rerun action and coverage in `app/Filament/Resources/RestoreRunResource.php` and `tests/Feature/RestoreRunRerunTest.php`. + +**Checkpoint**: Tests pass and formatting is clean. diff --git a/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php new file mode 100644 index 0000000..e86f144 --- /dev/null +++ b/tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php @@ -0,0 +1,111 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applyCalls++; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ca', + 'name' => 'Tenant CA', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'ca-policy-1', + 'policy_type' => 'conditionalAccessPolicy', + 'display_name' => 'CA Policy', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'CA 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' => [ + '@odata.type' => '#microsoft.graph.conditionalAccessPolicy', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'conditionalAccessPolicy'); + + expect($previewItem)->not->toBeNull() + ->and($previewItem['restore_mode'] ?? null)->toBe('preview-only'); + + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: 'tester@example.com', + actorName: 'Tester', + ); + + expect($run->results)->toHaveCount(1); + expect($run->results[0]['status'])->toBe('skipped'); + expect($run->results[0]['reason'])->toBe('preview_only'); + + expect($client->applyCalls)->toBe(0); +}); diff --git a/tests/Feature/Filament/RestoreExecutionTest.php b/tests/Feature/Filament/RestoreExecutionTest.php index 6397939..252aafe 100644 --- a/tests/Feature/Filament/RestoreExecutionTest.php +++ b/tests/Feature/Filament/RestoreExecutionTest.php @@ -8,8 +8,10 @@ use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use App\Services\Intune\FoundationMappingService; use App\Services\Intune\RestoreService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Mockery\MockInterface; uses(RefreshDatabase::class); @@ -101,3 +103,82 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); }); + +test('restore execution records foundation mappings', function () { + config()->set('tenantpilot.foundation_types', [ + [ + 'type' => 'assignmentFilter', + 'label' => 'Assignment Filter', + 'category' => 'Foundations', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/assignmentFilters', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'low', + ], + ]); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create(); + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'filter-1', + 'policy_type' => 'assignmentFilter', + 'platform' => 'all', + 'payload' => [ + 'id' => 'filter-1', + 'displayName' => 'Filter One', + ], + 'metadata' => [ + 'displayName' => 'Filter One', + ], + ]) + ->create(); + + $entries = [ + [ + 'type' => 'assignmentFilter', + 'sourceId' => 'filter-1', + 'sourceName' => 'Filter One', + 'decision' => 'created', + 'targetId' => 'filter-2', + 'targetName' => 'Filter One', + ], + ]; + + $this->mock(FoundationMappingService::class, function (MockInterface $mock) use ($entries) { + $mock->shouldReceive('map') + ->twice() + ->andReturn([ + 'entries' => $entries, + 'mapping' => ['filter-1' => 'filter-2'], + 'failed' => 0, + 'skipped' => 0, + ]); + }); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results)->toHaveCount(1); + expect($run->results[0]['decision'])->toBe('created'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.foundation.created', + 'resource_id' => (string) $run->id, + ]); +}); diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php new file mode 100644 index 0000000..0699485 --- /dev/null +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -0,0 +1,75 @@ +create(['status' => 'active']); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Policy Display', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'item_count' => 2, + ]); + + BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'payload' => ['id' => $policy->external_id], + ]) + ->create(); + + BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'tag-1', + 'policy_type' => 'roleScopeTag', + 'platform' => 'all', + 'payload' => [ + 'id' => 'tag-1', + 'displayName' => 'Scope Tag Alpha', + ], + 'metadata' => [ + 'displayName' => 'Scope Tag Alpha', + ], + ]) + ->create(); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->assertSee('Policy Display') + ->assertSee('Scope Tag Alpha') + ->assertSee('Settings Catalog Policy') + ->assertSee('Scope Tag') + ->assertSee('restore: enabled') + ->assertSee('id: policy-1') + ->assertSee('id: tag-1') + ->assertSee('Include foundations'); +}); diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index 84b644f..75196aa 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -76,9 +76,30 @@ public function request(string $method, string $path, array $options = []): Grap 'payload' => ['foo' => 'bar'], ]); + BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'filter-1', + 'policy_type' => 'assignmentFilter', + 'platform' => 'all', + 'payload' => [ + 'id' => 'filter-1', + 'displayName' => 'Filter One', + ], + 'metadata' => [ + 'displayName' => 'Filter One', + ], + ]); + $service = app(RestoreService::class); $preview = $service->preview($tenant, $backupSet); - expect($preview)->toHaveCount(1); - expect($preview[0]['action'])->toBe('update'); + expect($preview)->toHaveCount(2); + + $foundation = collect($preview)->first(fn (array $item) => isset($item['decision'])); + expect($foundation['decision'])->toBe('created'); + + $policyPreview = collect($preview)->first(fn (array $item) => isset($item['action'])); + expect($policyPreview['action'])->toBe('update'); }); diff --git a/tests/Feature/FoundationBackupTest.php b/tests/Feature/FoundationBackupTest.php new file mode 100644 index 0000000..2c29ada --- /dev/null +++ b/tests/Feature/FoundationBackupTest.php @@ -0,0 +1,95 @@ +tenant = Tenant::factory()->create(['status' => 'active']); + + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'display_name' => 'Test Policy', + ]); + + config()->set('tenantpilot.foundation_types', [ + [ + 'type' => 'assignmentFilter', + 'label' => 'Assignment Filter', + 'category' => 'Foundations', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/assignmentFilters', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'low', + ], + ]); + + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->andReturn([ + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceConfiguration', + 'id' => 'policy-1', + 'displayName' => 'Test Policy', + ], + 'metadata' => [], + 'warnings' => [], + ]); + }); + + $this->mock(FoundationSnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetchAll') + ->once() + ->andReturn([ + 'items' => [ + [ + 'source_id' => 'filter-1', + 'display_name' => 'Filter One', + 'payload' => [ + 'id' => 'filter-1', + 'displayName' => 'Filter One', + ], + 'metadata' => [ + 'displayName' => 'Filter One', + 'kind' => 'assignmentFilter', + 'graph' => [ + 'resource' => 'deviceManagement/assignmentFilters', + 'apiVersion' => 'beta', + ], + ], + ], + ], + 'failures' => [], + ]); + }); +}); + +it('creates foundation backup items when requested', function () { + $service = app(BackupService::class); + + $backupSet = $service->createBackupSet( + tenant: $this->tenant, + policyIds: [$this->policy->id], + name: 'Foundation Backup', + includeFoundations: true, + ); + + expect($backupSet->items)->toHaveCount(2); + + $foundationItem = BackupItem::query() + ->where('backup_set_id', $backupSet->id) + ->where('policy_type', 'assignmentFilter') + ->first(); + + expect($foundationItem)->not->toBeNull(); + expect($foundationItem->policy_id)->toBeNull(); + expect($foundationItem->policy_identifier)->toBe('filter-1'); + expect($foundationItem->metadata['displayName'])->toBe('Filter One'); +}); diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php index f2a2b4b..2d803b0 100644 --- a/tests/Feature/RestoreAssignmentApplicationTest.php +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -7,8 +7,10 @@ use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use App\Services\Intune\FoundationMappingService; use App\Services\Intune\RestoreService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Mockery\MockInterface; uses(RefreshDatabase::class); @@ -247,3 +249,109 @@ public function request(string $method, string $path, array $options = []): Grap expect($summary['failed'])->toBe(2); expect($run->results[0]['status'])->toBe('partial'); }); + +test('restore maps assignment filter identifiers', 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' => 2, + ]); + + $foundationItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'filter-old', + 'policy_type' => 'assignmentFilter', + 'platform' => 'all', + 'payload' => ['id' => 'filter-old', 'displayName' => 'Filter Old'], + 'metadata' => ['displayName' => 'Filter Old'], + ]); + + $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', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-old', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + ], + ], + ], + ]); + + $entries = [ + [ + 'type' => 'assignmentFilter', + 'sourceId' => 'filter-old', + 'sourceName' => 'Filter Old', + 'decision' => 'created', + 'targetId' => 'filter-new', + 'targetName' => 'Filter Old', + ], + ]; + + $this->mock(FoundationMappingService::class, function (MockInterface $mock) use ($entries) { + $mock->shouldReceive('map') + ->twice() + ->andReturn([ + 'entries' => $entries, + 'mapping' => ['filter-old' => 'filter-new'], + 'failed' => 0, + 'skipped' => 0, + ]); + }); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$foundationItem->id, $backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + ], + ); + + $payloadAssignments = $client->requestCalls[0]['payload']['assignments'] ?? []; + + expect($payloadAssignments[0]['target']['deviceAndAppManagementAssignmentFilterId'])->toBe('filter-new'); +}); diff --git a/tests/Feature/RestoreRunRerunTest.php b/tests/Feature/RestoreRunRerunTest.php new file mode 100644 index 0000000..a6014ef --- /dev/null +++ b/tests/Feature/RestoreRunRerunTest.php @@ -0,0 +1,68 @@ +create(); + $tenant->makeCurrent(); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'policy-1', + '@odata.type' => '#microsoft.graph.windows10CustomConfiguration', + ], + ]) + ->create(); + + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'status' => 'failed', + 'is_dry_run' => true, + 'requested_items' => [$backupItem->id], + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->callTableAction('rerun', $run); + + $newRun = RestoreRun::query() + ->where('backup_set_id', $backupSet->id) + ->orderByDesc('id') + ->first(); + + expect($newRun)->not->toBeNull(); + expect($newRun->id)->not->toBe($run->id); + expect($newRun->requested_items)->toBe([$backupItem->id]); + expect($newRun->group_mapping)->toBe([ + 'source-group-1' => 'target-group-1', + ]); + expect($newRun->is_dry_run)->toBeTrue(); + expect($newRun->requested_by)->toBe('tester@example.com'); +}); diff --git a/tests/Feature/RestoreScopeTagMappingTest.php b/tests/Feature/RestoreScopeTagMappingTest.php new file mode 100644 index 0000000..9eab0e3 --- /dev/null +++ b/tests/Feature/RestoreScopeTagMappingTest.php @@ -0,0 +1,143 @@ + + */ + public array $applyPolicyCalls = []; + + 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 + { + $this->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + ]; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore applies scope tag mappings to policy payloads', function () { + $client = new RestoreScopeTagGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 2, + ]); + + $scopeTagItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'tag-old', + 'policy_type' => 'roleScopeTag', + 'platform' => 'all', + 'payload' => [ + 'id' => 'tag-old', + 'displayName' => 'Scope Tag Alpha', + ], + 'metadata' => [ + 'displayName' => 'Scope Tag Alpha', + ], + ]) + ->create(); + + $policyItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'policy-1', + 'displayName' => 'Policy Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'roleScopeTagIds' => ['tag-old'], + ], + ]) + ->create(); + + $entries = [ + [ + 'type' => 'roleScopeTag', + 'sourceId' => 'tag-old', + 'sourceName' => 'Scope Tag Alpha', + 'decision' => 'created', + 'targetId' => 'tag-new', + 'targetName' => 'Scope Tag Alpha', + ], + ]; + + $this->mock(FoundationMappingService::class, function (MockInterface $mock) use ($entries) { + $mock->shouldReceive('map') + ->twice() + ->andReturn([ + 'entries' => $entries, + 'mapping' => ['tag-old' => 'tag-new'], + 'failed' => 0, + 'skipped' => 0, + ]); + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$scopeTagItem->id, $policyItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['tag-new']); +}); diff --git a/tests/Unit/FoundationMappingServiceTest.php b/tests/Unit/FoundationMappingServiceTest.php new file mode 100644 index 0000000..51a73ca --- /dev/null +++ b/tests/Unit/FoundationMappingServiceTest.php @@ -0,0 +1,281 @@ + $responses + */ + public function __construct(private array $responses = []) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [ + 'method' => $method, + 'path' => $path, + 'options' => $options, + ]; + + return array_shift($this->responses) ?? new GraphResponse(success: true, data: []); + } +} + +it('maps existing foundations by display name', function () { + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create(); + $item = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'filter-1', + 'policy_type' => 'assignmentFilter', + 'platform' => 'all', + 'payload' => [ + 'id' => 'filter-1', + 'displayName' => 'Filter One', + ], + 'metadata' => [ + 'displayName' => 'Filter One', + ], + ]) + ->create(); + + $this->mock(FoundationSnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetchAll') + ->once() + ->andReturn([ + 'items' => [ + [ + 'source_id' => 'filter-2', + 'display_name' => 'Filter One', + 'payload' => [], + 'metadata' => [], + ], + ], + 'failures' => [], + ]); + }); + + $client = new FoundationMappingGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $service = app(FoundationMappingService::class); + $result = $service->map($tenant, collect([$item]), false); + + expect($result['failed'])->toBe(0); + expect($result['skipped'])->toBe(0); + expect($result['mapping'])->toBe(['filter-1' => 'filter-2']); + expect($result['entries'])->toHaveCount(1); + expect($result['entries'][0]['decision'])->toBe('mapped_existing'); + expect($result['entries'][0]['targetId'])->toBe('filter-2'); + expect($result['entries'][0]['sourceName'])->toBe('Filter One'); +}); + +it('creates missing foundations when executing', function () { + config()->set('graph_contracts.types.assignmentFilter', [ + 'resource' => 'deviceManagement/assignmentFilters', + 'create_method' => 'POST', + 'update_strip_keys' => ['isBuiltIn'], + ]); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-1', + 'app_client_id' => 'client-1', + 'app_client_secret' => 'secret-1', + ]); + $backupSet = BackupSet::factory()->for($tenant)->create(); + $item = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'filter-1', + 'policy_type' => 'assignmentFilter', + 'platform' => 'all', + 'payload' => [ + 'id' => 'filter-1', + '@odata.type' => '#microsoft.graph.deviceAndAppManagementAssignmentFilter', + 'displayName' => 'Filter One', + 'isBuiltIn' => false, + ], + 'metadata' => [ + 'displayName' => 'Filter One', + ], + ]) + ->create(); + + $this->mock(FoundationSnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetchAll') + ->once() + ->andReturn([ + 'items' => [ + [ + 'source_id' => 'filter-2', + 'display_name' => 'Filter One', + 'payload' => [], + 'metadata' => [], + ], + [ + 'source_id' => 'filter-3', + 'display_name' => 'Filter One', + 'payload' => [], + 'metadata' => [], + ], + ], + 'failures' => [], + ]); + }); + + $client = new FoundationMappingGraphClient([ + new GraphResponse(true, [ + 'id' => 'filter-99', + 'displayName' => 'Filter One (Copy)', + ]), + ]); + app()->instance(GraphClientInterface::class, $client); + + $service = app(FoundationMappingService::class); + $result = $service->map($tenant, collect([$item]), true); + + expect($result['mapping'])->toBe(['filter-1' => 'filter-99']); + expect($result['entries'][0]['decision'])->toBe('created_copy'); + expect($result['entries'][0]['targetName'])->toBe('Filter One (Copy)'); + expect($client->requests)->toHaveCount(1); + expect($client->requests[0]['method'])->toBe('POST'); + expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); + + $payload = $client->requests[0]['options']['json']; + expect($payload['displayName'])->toBe('Filter One (Copy)'); + expect($payload)->not->toHaveKey('id'); + expect($payload)->not->toHaveKey('@odata.type'); + expect($payload)->not->toHaveKey('isBuiltIn'); +}); + +it('skips built-in scope tags', function () { + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create(); + $item = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => '0', + 'policy_type' => 'roleScopeTag', + 'platform' => 'all', + 'payload' => [ + 'id' => '0', + 'displayName' => 'Default', + 'isBuiltIn' => true, + ], + 'metadata' => [ + 'displayName' => 'Default', + ], + ]) + ->create(); + + $this->mock(FoundationSnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetchAll') + ->once() + ->andReturn([ + 'items' => [], + 'failures' => [], + ]); + }); + + $client = new FoundationMappingGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $service = app(FoundationMappingService::class); + $result = $service->map($tenant, collect([$item]), false); + + expect($result['skipped'])->toBe(1); + expect($result['entries'][0]['decision'])->toBe('skipped'); + expect($result['entries'][0]['reason'])->toBe('Built-in scope tag cannot be created.'); +}); + +it('marks failures when foundation listing fails', function () { + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create(); + $item = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => null, + 'policy_identifier' => 'filter-1', + 'policy_type' => 'assignmentFilter', + 'platform' => 'all', + 'payload' => [ + 'id' => 'filter-1', + 'displayName' => 'Filter One', + ], + 'metadata' => [ + 'displayName' => 'Filter One', + ], + ]) + ->create(); + + $this->mock(FoundationSnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetchAll') + ->once() + ->andReturn([ + 'items' => [], + 'failures' => [ + [ + 'foundation_type' => 'assignmentFilter', + 'reason' => 'Graph failure', + 'status' => 500, + ], + ], + ]); + }); + + $client = new FoundationMappingGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $service = app(FoundationMappingService::class); + $result = $service->map($tenant, collect([$item]), false); + + expect($result['failed'])->toBe(1); + expect($result['entries'][0]['decision'])->toBe('failed'); + expect($result['entries'][0]['reason'])->toBe('Graph failure'); +}); diff --git a/tests/Unit/FoundationSnapshotServiceTest.php b/tests/Unit/FoundationSnapshotServiceTest.php new file mode 100644 index 0000000..bcd1f8d --- /dev/null +++ b/tests/Unit/FoundationSnapshotServiceTest.php @@ -0,0 +1,121 @@ + $responses + */ + public function __construct(private array $responses) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [ + 'method' => $method, + 'path' => $path, + 'options' => $options, + ]; + + return array_shift($this->responses) ?? new GraphResponse(success: true, data: []); + } +} + +it('returns a failure when the foundation contract is missing', function () { + $tenant = Tenant::factory()->create(); + + $client = new FoundationSnapshotGraphClient([]); + app()->instance(GraphClientInterface::class, $client); + + $service = app(FoundationSnapshotService::class); + $result = $service->fetchAll($tenant, 'unknownFoundation'); + + expect($result['items'])->toBeEmpty(); + expect($result['failures'])->toHaveCount(1); + expect($result['failures'][0]['foundation_type'])->toBe('unknownFoundation'); +}); + +it('hydrates foundation snapshots across pages with metadata', function () { + config()->set('graph_contracts.types.assignmentFilter', [ + 'resource' => 'deviceManagement/assignmentFilters', + 'allowed_select' => ['id', 'displayName'], + ]); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-123', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + ]); + + $responses = [ + new GraphResponse( + success: true, + data: [ + 'value' => [ + ['id' => 'filter-1', 'displayName' => 'Filter One'], + ], + '@odata.nextLink' => 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?$skiptoken=abc', + ], + ), + new GraphResponse( + success: true, + data: [ + 'value' => [ + ['id' => 'filter-2', 'displayName' => 'Filter Two'], + ], + ], + ), + ]; + + $client = new FoundationSnapshotGraphClient($responses); + app()->instance(GraphClientInterface::class, $client); + + $service = app(FoundationSnapshotService::class); + $result = $service->fetchAll($tenant, 'assignmentFilter'); + + expect($result['items'])->toHaveCount(2); + expect($result['items'][0]['source_id'])->toBe('filter-1'); + expect($result['items'][0]['metadata']['displayName'])->toBe('Filter One'); + expect($result['items'][0]['metadata']['kind'])->toBe('assignmentFilter'); + expect($result['items'][1]['source_id'])->toBe('filter-2'); + + expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); + expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']); + expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc'); + expect($client->requests[1]['options']['query'])->toBe([]); +}); diff --git a/tests/Unit/TenantPermissionServiceTest.php b/tests/Unit/TenantPermissionServiceTest.php index 74c2c51..b9dace7 100644 --- a/tests/Unit/TenantPermissionServiceTest.php +++ b/tests/Unit/TenantPermissionServiceTest.php @@ -121,3 +121,35 @@ function requiredPermissions(): array 'status' => 'error', ]); }); + +it('ignores configured stub permissions when requested', function () { + $originalPermissions = config('intune_permissions.permissions'); + $originalStub = config('intune_permissions.granted_stub'); + + config()->set('intune_permissions.permissions', [ + [ + 'key' => 'DeviceManagementRBAC.ReadWrite.All', + 'type' => 'application', + 'description' => null, + 'features' => [], + ], + ]); + config()->set('intune_permissions.granted_stub', ['DeviceManagementRBAC.ReadWrite.All']); + + $tenant = Tenant::factory()->create(); + + TenantPermission::create([ + 'tenant_id' => $tenant->id, + 'permission_key' => 'DeviceManagementRBAC.ReadWrite.All', + 'status' => 'granted', + 'details' => ['source' => 'configured'], + ]); + + $result = app(TenantPermissionService::class)->compare($tenant, persist: false, useConfiguredStub: false); + + expect($result['overall_status'])->toBe('missing'); + expect($result['permissions'][0]['status'])->toBe('missing'); + + config()->set('intune_permissions.permissions', $originalPermissions); + config()->set('intune_permissions.granted_stub', $originalStub); +});