diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index dd20cf2..eddf393 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -30,6 +30,11 @@ public function table(Table $table): Table ->sortable() ->searchable() ->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()), + Tables\Columns\TextColumn::make('policyVersion.version_number') + ->label('Version') + ->badge() + ->default('—') + ->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number), Tables\Columns\TextColumn::make('policy_type') ->label('Type') ->badge() diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 84a7560..56f42cc 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -2,7 +2,13 @@ namespace App\Filament\Resources\PolicyResource\RelationManagers; +use App\Filament\Resources\RestoreRunResource; +use App\Models\PolicyVersion; +use App\Models\Tenant; +use App\Services\Intune\RestoreService; use Filament\Actions; +use Filament\Forms; +use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; @@ -24,6 +30,55 @@ public function table(Table $table): Table ->filters([]) ->headerActions([]) ->actions([ + Actions\Action::make('restore_to_intune') + ->label('Restore to Intune') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('danger') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") + ->modalSubheading('Creates a restore run using this policy version snapshot.') + ->form([ + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true), + ]) + ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { + $tenant = Tenant::current(); + + if ($record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + try { + $run = $restoreService->executeFromPolicyVersion( + tenant: $tenant, + version: $record, + dryRun: (bool) ($data['is_dry_run'] ?? true), + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + ); + } catch (\Throwable $throwable) { + Notification::make() + ->title('Restore run failed to start') + ->body($throwable->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title('Restore run started') + ->success() + ->send(); + + return redirect(RestoreRunResource::getUrl('view', ['record' => $run])); + }), Actions\ViewAction::make() ->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index bbc4d25..3f7088d 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -548,7 +548,7 @@ private static function restoreItemOptionData(?int $backupSetId): array ->orWhereDoesntHave('policy') ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); }) - ->with('policy:id,display_name') + ->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at']) ->get() ->sortBy(function (BackupItem $item) { $meta = static::typeMeta($item->policy_type); @@ -570,6 +570,7 @@ private static function restoreItemOptionData(?int $backupSetId): array $platform = $item->platform ?? $meta['platform'] ?? null; $displayName = $item->resolvedDisplayName(); $identifier = $item->policy_identifier ?? null; + $versionNumber = $item->policyVersion?->version_number; $options[$item->id] = $displayName; @@ -578,6 +579,7 @@ private static function restoreItemOptionData(?int $backupSetId): array $typeLabel, $platform, "restore: {$restore}", + $versionNumber ? "version: {$versionNumber}" : null, $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, $identifier ? 'id: '.Str::limit($identifier, 24, '...') : null, ]); diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 4ee0cac..d5082b1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -5,6 +5,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\RestoreRun; use App\Models\Tenant; use App\Services\AssignmentRestoreService; @@ -97,6 +98,88 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt * * @param array|null $selectedItemIds */ + public function executeFromPolicyVersion( + Tenant $tenant, + PolicyVersion $version, + bool $dryRun = true, + ?string $actorEmail = null, + ?string $actorName = null, + array $groupMapping = [], + ): RestoreRun { + if ($version->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Policy version does not belong to the provided tenant.'); + } + + $policy = $version->policy; + + if (! $policy) { + throw new \RuntimeException('Policy version has no associated policy.'); + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $version->version_number + ), + 'created_by' => $actorEmail, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $version->id, + 'policy_version_number' => $version->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($version->scope_tags) ? $version->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ + 'source' => 'policy_version', + 'display_name' => $policy->display_name, + 'policy_version_id' => $version->id, + 'policy_version_number' => $version->version_number, + 'version_captured_at' => $version->captured_at?->toIso8601String(), + ]; + + if (is_array($scopeTagIds) && $scopeTagIds !== []) { + $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; + } + + if (is_array($scopeTagNames) && $scopeTagNames !== []) { + $backupItemMetadata['scope_tag_names'] = $scopeTagNames; + } + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_version_id' => $version->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $version->captured_at ?? CarbonImmutable::now(), + 'payload' => $version->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $version->assignments, + ]); + + return $this->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: $dryRun, + actorEmail: $actorEmail, + actorName: $actorName, + groupMapping: $groupMapping, + ); + } + public function execute( Tenant $tenant, BackupSet $backupSet, diff --git a/specs/010-admin-templates/tasks.md b/specs/010-admin-templates/tasks.md index c1ba6f3..c34d9fd 100644 --- a/specs/010-admin-templates/tasks.md +++ b/specs/010-admin-templates/tasks.md @@ -14,6 +14,7 @@ ## Phase 3: UI Normalization - [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable). - [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`. - [x] T010 Ensure restore-created versions keep assignments + show scope tags independently. +- [x] T012 Add “Restore to Intune” from a specific PolicyVersion (rollback) + show policy version numbers in restore selection UI. ## Phase 4: Tests + Verification - [x] T005 Add tests for hydration + UI display. diff --git a/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php new file mode 100644 index 0000000..d32d790 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php @@ -0,0 +1,153 @@ + + */ + public array $requestCalls = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $method = strtoupper($method); + + $this->requestCalls[] = [ + 'method' => $method, + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + if ($method === 'GET') { + return new GraphResponse(true, ['value' => []]); + } + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore can execute from a specific policy version snapshot', function () { + $client = new PolicyVersionRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-version-restore', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'gpo-versioned-1', + 'policy_type' => 'groupPolicyConfiguration', + 'display_name' => 'Admin Templates', + 'platform' => 'windows', + ]); + + $snapshotWithThree = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + 'definitionValues' => collect(range(1, 3)) + ->map(fn (int $i) => [ + 'enabled' => true, + 'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')", + '#Definition_Id' => "def-{$i}", + '#Definition_displayName' => "Setting {$i}", + ])->all(), + ]; + + $snapshotWithFive = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => '#microsoft.graph.groupPolicyConfiguration', + 'definitionValues' => collect(range(1, 5)) + ->map(fn (int $i) => [ + 'enabled' => true, + 'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')", + '#Definition_Id' => "def-{$i}", + '#Definition_displayName' => "Setting {$i}", + ])->all(), + ]; + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => now(), + 'snapshot' => $snapshotWithThree, + 'metadata' => ['source' => 'version_capture'], + ]); + + $versionToRestore = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => now(), + 'snapshot' => $snapshotWithFive, + 'metadata' => ['source' => 'version_capture'], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->executeFromPolicyVersion( + tenant: $tenant, + version: $versionToRestore, + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + )->refresh(); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + expect($run->results[0]['definition_value_summary']['success'])->toBe(5); + + $definitionValueCreateCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues')) + ->values(); + + expect($definitionValueCreateCalls)->toHaveCount(5); +});