TenantAtlas/app/Filament/Resources/RestoreRunResource.php
ahmido f4cf1dce6e feat/004-assignments-scope-tags (#4)
## 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: #4
2025-12-23 21:49:58 +00:00

453 lines
18 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RestoreRunResource\Pages;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\RestoreService;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum;
class RestoreRunResource extends Resource
{
protected static ?string $model = RestoreRun::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(function () {
$tenantId = Tenant::current()->getKey();
return BackupSet::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->orderByDesc('created_at')
->get()
->mapWithKeys(function (BackupSet $set) {
$label = sprintf(
'%s • %s items • %s',
$set->name,
$set->item_count ?? 0,
optional($set->created_at)->format('Y-m-d H:i')
);
return [$set->id => $label];
});
})
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('backup_item_ids', []);
$set('group_mapping', []);
})
->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)
->reactive()
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
->helperText('Preview-only types stay in dry-run; leave empty to include all items.'),
Section::make('Group mapping')
->description('Some source groups do not exist in the target tenant. Map them or choose Skip.')
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
);
return array_map(function (array $group) use ($tenant): Forms\Components\Select {
$groupId = $group['id'];
$label = $group['label'];
return Forms\Components\Select::make("group_mapping.{$groupId}")
->label($label)
->options([
'SKIP' => 'Skip assignment',
])
->searchable()
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value))
->required()
->helperText('Choose a target group or select Skip.');
}, $unresolved);
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = Tenant::current();
if (! $tenant || ! $backupSetId) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
) !== [];
}),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('backupSet.name')->label('Backup set'),
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
Tables\Columns\TextColumn::make('status')->badge(),
Tables\Columns\TextColumn::make('started_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since(),
Tables\Columns\TextColumn::make('requested_by')->label('Requested by'),
])
->filters([
Tables\Filters\TrashedFilter::make(),
])
->actions([
Actions\ViewAction::make(),
ActionGroup::make([
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (RestoreRun $record) => ! $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record->delete();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'restore_run.deleted',
resourceType: 'restore_run',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
);
}
Notification::make()
->title('Restore run archived')
->success()
->send();
}),
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (RestoreRun $record) => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'restore_run.force_deleted',
resourceType: 'restore_run',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
);
}
$record->forceDelete();
Notification::make()
->title('Restore run permanently deleted')
->success()
->send();
}),
])->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
Infolists\Components\TextEntry::make('status')->badge(),
Infolists\Components\TextEntry::make('is_dry_run')
->label('Dry-run')
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
->badge(),
Infolists\Components\TextEntry::make('requested_by'),
Infolists\Components\TextEntry::make('started_at')->dateTime(),
Infolists\Components\TextEntry::make('completed_at')->dateTime(),
Infolists\Components\ViewEntry::make('preview')
->label('Preview')
->view('filament.infolists.entries.restore-preview')
->state(fn ($record) => $record->preview ?? []),
Infolists\Components\ViewEntry::make('results')
->label('Results')
->view('filament.infolists.entries.restore-results')
->state(fn ($record) => $record->results ?? []),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRestoreRuns::route('/'),
'create' => Pages\CreateRestoreRun::route('/create'),
'view' => Pages\ViewRestoreRun::route('/{record}'),
];
}
/**
* @return array{label:?string,category:?string,restore:?string,risk:?string}|array<string,mixed>
*/
private static function typeMeta(?string $type): array
{
if ($type === null) {
return [];
}
return collect(config('tenantpilot.supported_policy_types', []))
->firstWhere('type', $type) ?? [];
}
public static function createRestoreRun(array $data): RestoreRun
{
/** @var Tenant $tenant */
$tenant = Tenant::current();
/** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
if ($backupSet->tenant_id !== $tenant->id) {
abort(403, 'Backup set does not belong to the active tenant.');
}
/** @var RestoreService $service */
$service = app(RestoreService::class);
return $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $data['backup_item_ids'] ?? null,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
groupMapping: $data['group_mapping'] ?? [],
);
}
/**
* @param array<int>|null $selectedItemIds
* @return array<int, array{id:string,label:string}>
*/
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array
{
if (! $backupSetId) {
return [];
}
$query = BackupItem::query()->where('backup_set_id', $backupSetId);
if ($selectedItemIds !== null) {
$query->whereIn('id', $selectedItemIds);
}
$items = $query->get(['assignments']);
$assignments = [];
$sourceNames = [];
foreach ($items as $item) {
if (! is_array($item->assignments) || $item->assignments === []) {
continue;
}
foreach ($item->assignments as $assignment) {
if (! is_array($assignment)) {
continue;
}
$target = $assignment['target'] ?? [];
$odataType = $target['@odata.type'] ?? '';
if (! in_array($odataType, [
'#microsoft.graph.groupAssignmentTarget',
'#microsoft.graph.exclusionGroupAssignmentTarget',
], true)) {
continue;
}
$groupId = $target['groupId'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$assignments[] = $groupId;
$displayName = $target['group_display_name'] ?? null;
if (is_string($displayName) && $displayName !== '') {
$sourceNames[$groupId] = $displayName;
}
}
}
$groupIds = array_values(array_unique($assignments));
if ($groupIds === []) {
return [];
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
$unresolved = [];
foreach ($groupIds as $groupId) {
$group = $resolved[$groupId] ?? null;
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
continue;
}
$label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId);
$unresolved[] = [
'id' => $groupId,
'label' => $label,
];
}
return $unresolved;
}
/**
* @return array<string, string>
*/
private static function targetGroupOptions(Tenant $tenant, string $search): array
{
if (mb_strlen($search) < 2) {
return [];
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups',
[
'query' => [
'$filter' => sprintf(
"securityEnabled eq true and startswith(displayName,'%s')",
static::escapeOdataValue($search)
),
'$select' => 'id,displayName',
'$top' => 20,
],
] + $tenant->graphOptions()
);
} catch (\Throwable) {
return [];
}
if ($response->failed()) {
return [];
}
return collect($response->data['value'] ?? [])
->filter(fn (array $group) => filled($group['id'] ?? null))
->mapWithKeys(fn (array $group) => [
$group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']),
])
->all();
}
private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string
{
if (! $groupId) {
return $groupId;
}
if ($groupId === 'SKIP') {
return 'Skip assignment';
}
$graphOptions = $tenant->graphOptions();
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
$resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions);
$group = $resolved[$groupId] ?? null;
return static::formatGroupLabel($group['displayName'] ?? null, $groupId);
}
private static function formatGroupLabel(?string $displayName, string $id): string
{
$suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
return trim(($displayName ?: 'Security group').$suffix);
}
private static function escapeOdataValue(string $value): string
{
return str_replace("'", "''", $value);
}
}