feat: restore from PolicyVersion snapshots
This commit is contained in:
parent
196bf89c8a
commit
f4d00f954a
@ -30,6 +30,11 @@ public function table(Table $table): Table
|
|||||||
->sortable()
|
->sortable()
|
||||||
->searchable()
|
->searchable()
|
||||||
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
|
->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')
|
Tables\Columns\TextColumn::make('policy_type')
|
||||||
->label('Type')
|
->label('Type')
|
||||||
->badge()
|
->badge()
|
||||||
|
|||||||
@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
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\Actions;
|
||||||
|
use Filament\Forms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
@ -24,6 +30,55 @@ public function table(Table $table): Table
|
|||||||
->filters([])
|
->filters([])
|
||||||
->headerActions([])
|
->headerActions([])
|
||||||
->actions([
|
->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()
|
Actions\ViewAction::make()
|
||||||
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
|
||||||
->openUrlInNewTab(false),
|
->openUrlInNewTab(false),
|
||||||
|
|||||||
@ -548,7 +548,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
->orWhereDoesntHave('policy')
|
->orWhereDoesntHave('policy')
|
||||||
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
|
->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()
|
->get()
|
||||||
->sortBy(function (BackupItem $item) {
|
->sortBy(function (BackupItem $item) {
|
||||||
$meta = static::typeMeta($item->policy_type);
|
$meta = static::typeMeta($item->policy_type);
|
||||||
@ -570,6 +570,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$platform = $item->platform ?? $meta['platform'] ?? null;
|
$platform = $item->platform ?? $meta['platform'] ?? null;
|
||||||
$displayName = $item->resolvedDisplayName();
|
$displayName = $item->resolvedDisplayName();
|
||||||
$identifier = $item->policy_identifier ?? null;
|
$identifier = $item->policy_identifier ?? null;
|
||||||
|
$versionNumber = $item->policyVersion?->version_number;
|
||||||
|
|
||||||
$options[$item->id] = $displayName;
|
$options[$item->id] = $displayName;
|
||||||
|
|
||||||
@ -578,6 +579,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
|||||||
$typeLabel,
|
$typeLabel,
|
||||||
$platform,
|
$platform,
|
||||||
"restore: {$restore}",
|
"restore: {$restore}",
|
||||||
|
$versionNumber ? "version: {$versionNumber}" : null,
|
||||||
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
|
||||||
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\AssignmentRestoreService;
|
use App\Services\AssignmentRestoreService;
|
||||||
@ -97,6 +98,88 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
*
|
*
|
||||||
* @param array<int>|null $selectedItemIds
|
* @param array<int>|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(
|
public function execute(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BackupSet $backupSet,
|
BackupSet $backupSet,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ ## Phase 3: UI Normalization
|
|||||||
- [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable).
|
- [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable).
|
||||||
- [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`.
|
- [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`.
|
||||||
- [x] T010 Ensure restore-created versions keep assignments + show scope tags independently.
|
- [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
|
## Phase 4: Tests + Verification
|
||||||
- [x] T005 Add tests for hydration + UI display.
|
- [x] T005 Add tests for hydration + UI display.
|
||||||
|
|||||||
153
tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php
Normal file
153
tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\PolicyVersion;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Services\Graph\GraphResponse;
|
||||||
|
use App\Services\Intune\RestoreService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
class PolicyVersionRestoreGraphClient implements GraphClientInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var array<int, array{method:string,path:string,payload:array|null}>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user