TenantAtlas/app/Filament/Resources/PolicyVersionResource.php
ahmido e19aa09ae0 feat(wizard): Add restore from policy version (#15)
Implements the "Restore via Wizard" action on the PolicyVersion resource.

This allows a user to initiate a restore run directly from a specific policy version snapshot.

- Adds a "Restore via Wizard" action to the PolicyVersion table.
- This action creates a single-item BackupSet from the selected version.
- The CreateRestoreRun wizard is now pre-filled from query parameters.
- Adds feature tests to cover the new workflow.
- Updates tasks.md to reflect the completed work.

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #15
2025-12-31 19:02:28 +00:00

538 lines
28 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PolicyVersionResource\Pages;
use App\Jobs\BulkPolicyVersionForceDeleteJob;
use App\Jobs\BulkPolicyVersionPruneJob;
use App\Jobs\BulkPolicyVersionRestoreJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\BulkOperationService;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicyNormalizer;
use App\Services\Intune\VersionDiff;
use BackedEnum;
use Carbon\CarbonImmutable;
use Filament\Actions;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use UnitEnum;
class PolicyVersionResource extends Resource
{
protected static ?string $model = PolicyVersion::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Infolists\Components\TextEntry::make('policy.display_name')->label('Policy'),
Infolists\Components\TextEntry::make('version_number')->label('Version'),
Infolists\Components\TextEntry::make('policy_type'),
Infolists\Components\TextEntry::make('platform'),
Infolists\Components\TextEntry::make('created_by')->label('Actor'),
Infolists\Components\TextEntry::make('captured_at')->dateTime(),
Tabs::make()
->activeTab(1)
->persistTabInQueryString('tab')
->columnSpanFull()
->tabs([
Tab::make('Normalized settings')
->id('normalized-settings')
->schema([
Infolists\Components\ViewEntry::make('normalized_settings_catalog')
->view('filament.infolists.entries.normalized-settings')
->state(function (PolicyVersion $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
is_array($record->snapshot) ? $record->snapshot : [],
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
})
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'),
Infolists\Components\ViewEntry::make('normalized_settings_standard')
->view('filament.infolists.entries.policy-settings-standard')
->state(function (PolicyVersion $record) {
$normalized = app(PolicyNormalizer::class)->normalize(
is_array($record->snapshot) ? $record->snapshot : [],
$record->policy_type ?? '',
$record->platform
);
$normalized['context'] = 'version';
$normalized['record_id'] = (string) $record->getKey();
return $normalized;
})
->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'),
]),
Tab::make('Raw JSON')
->id('raw-json')
->schema([
Infolists\Components\ViewEntry::make('snapshot_pretty')
->view('filament.infolists.entries.snapshot-json')
->state(fn (PolicyVersion $record) => $record->snapshot ?? []),
]),
Tab::make('Diff')
->id('diff')
->schema([
Infolists\Components\ViewEntry::make('normalized_diff')
->view('filament.infolists.entries.normalized-diff')
->state(function (PolicyVersion $record) {
$normalizer = app(PolicyNormalizer::class);
$diff = app(VersionDiff::class);
$previous = $record->previous();
$from = $previous
? $normalizer->flattenForDiff($previous->snapshot ?? [], $previous->policy_type ?? '', $previous->platform)
: [];
$to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform);
return $diff->compare($from, $to);
}),
Infolists\Components\ViewEntry::make('diff_json')
->label('Raw diff (advanced)')
->view('filament.infolists.entries.snapshot-json')
->state(function (PolicyVersion $record) {
$previous = $record->previous();
if (! $previous) {
return ['summary' => 'No previous version'];
}
$diff = app(VersionDiff::class)->compare(
$previous->snapshot ?? [],
$record->snapshot ?? []
);
$filter = static fn (array $items): array => array_filter(
$items,
static fn (mixed $value, string $key): bool => ! str_contains($key, '@odata.context'),
ARRAY_FILTER_USE_BOTH
);
$added = $filter($diff['added'] ?? []);
$removed = $filter($diff['removed'] ?? []);
$changed = $filter($diff['changed'] ?? []);
return [
'summary' => [
'added' => count($added),
'removed' => count($removed),
'changed' => count($changed),
'message' => sprintf(
'%d added, %d removed, %d changed',
count($added),
count($removed),
count($changed)
),
],
'added' => $added,
'removed' => $removed,
'changed' => $changed,
];
}),
]),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('policy.display_name')->label('Policy')->sortable()->searchable(),
Tables\Columns\TextColumn::make('version_number')->sortable(),
Tables\Columns\TextColumn::make('policy_type')->badge(),
Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('created_by')->label('Actor'),
Tables\Columns\TextColumn::make('captured_at')->dateTime()->sortable(),
])
->filters([
TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived'),
])
->actions([
Actions\ViewAction::make()
->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),
Actions\ActionGroup::make([
Actions\Action::make('restore_via_wizard')
->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square')
->color('primary')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
->action(function (PolicyVersion $record) {
$tenant = Tenant::current();
$user = auth()->user();
if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
$policy = $record->policy;
if (! $policy) {
Notification::make()
->title('Policy could not be found for this version')
->danger()
->send();
return;
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$record->version_number
),
'created_by' => $user?->email,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'policy_id' => $policy->id,
],
]);
$scopeTags = is_array($record->scope_tags) ? $record->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $record->id,
'policy_version_number' => $record->version_number,
'version_captured_at' => $record->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' => $record->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $record->captured_at ?? CarbonImmutable::now(),
'payload' => $record->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $record->assignments,
]);
return redirect()->to(RestoreRunResource::getUrl('create', [
'backup_set_id' => $backupSet->id,
'scope_mode' => 'selected',
'backup_item_ids' => [$backupItem->id],
]));
}),
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => ! $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.deleted',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
Notification::make()
->title('Policy version archived')
->success()
->send();
}),
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.force_deleted',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
$record->forceDelete();
Notification::make()
->title('Policy version permanently deleted')
->success()
->send();
}),
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed())
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
$record->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'policy_version.restored',
resourceType: 'policy_version',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['policy_id' => $record->policy_id, 'version' => $record->version_number]]
);
}
Notification::make()
->title('Policy version restored')
->success()
->send();
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('bulk_prune_versions')
->label('Prune Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->modalDescription('Only versions captured more than the specified retention window (in days) are eligible. Newer versions will be skipped.')
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return $isOnlyTrashed;
})
->form(function (Collection $records) {
$fields = [
Forms\Components\TextInput::make('retention_days')
->label('Retention Days')
->helperText('Versions captured within the last N days will be skipped.')
->numeric()
->required()
->default(90)
->minValue(1),
];
if ($records->count() >= 20) {
$fields[] = Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]);
}
return $fields;
})
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$retentionDays = (int) ($data['retention_days'] ?? 90);
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk prune started')
->body("Pruning {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
} else {
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_restore_versions')
->label('Restore Versions')
->icon('heroicon-o-arrow-uturn-left')
->color('success')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk restore started')
->body("Restoring {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyVersionRestoreJob::dispatch($run->id);
} else {
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
BulkAction::make('bulk_force_delete_versions')
->label('Force Delete Versions')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null;
$isOnlyTrashed = in_array($value, [0, '0', false], true);
return ! $isOnlyTrashed;
})
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->required()
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
])
->action(function (Collection $records, array $data) {
$tenant = Tenant::current();
$user = auth()->user();
$count = $records->count();
$ids = $records->pluck('id')->toArray();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
if ($count >= 20) {
Notification::make()
->title('Bulk force delete started')
->body("Force deleting {$count} policy versions in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->info()
->duration(8000)
->sendToDatabase($user)
->send();
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
} else {
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
}
})
->deselectRecordsAfterCompletion(),
]),
]);
}
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
->with('policy');
}
public static function getPages(): array
{
return [
'index' => Pages\ListPolicyVersions::route('/'),
'view' => Pages\ViewPolicyVersion::route('/{record}'),
];
}
}