TenantAtlas/apps/platform/app/Filament/Resources/RestoreRunResource.php
Ahmed Darrazi 9e435ea91f
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m2s
feat: implement explicit UiActionContext contract
2026-06-07 13:12:02 +02:00

3226 lines
152 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Contracts\Hardening\WriteGateInterface;
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Concerns\WorkspaceScopedEnvironmentRoutes;
use App\Filament\Resources\RestoreRunResource\Pages;
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter;
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunDetailPresenter;
use App\Jobs\BulkRestoreRunDeleteJob;
use App\Jobs\BulkRestoreRunForceDeleteJob;
use App\Jobs\BulkRestoreRunRestoreJob;
use App\Jobs\ExecuteRestoreRunJob;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\RestoreRun;
use App\Models\User;
use App\Models\Workspace;
use App\Rules\SkipOrUuidRule;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\CapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RestoreDiffGenerator;
use App\Services\Intune\RestoreRiskChecker;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\BackupQuality\BackupQualityResolver;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Filament\FilterOptionCatalog;
use App\Support\Filament\FilterPresets;
use App\Support\Navigation\NavigationScope;
use App\Support\OperationalControls\OperationalControlBlockedException;
use App\Support\OperationalControls\OperationalControlEvaluator;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
use App\Support\Rbac\Actions\ResolvesUiActionContext;
use App\Support\Rbac\Actions\UiActionContext;
use App\Support\Rbac\UiEnforcement;
use App\Support\RestoreRunIdempotency;
use App\Support\RestoreRunStatus;
use App\Support\RestoreSafety\ChecksIntegrityState;
use App\Support\RestoreSafety\PreviewIntegrityState;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
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\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Support\Exceptions\Halt;
use Filament\Tables;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use UnitEnum;
class RestoreRunResource extends Resource
{
use InteractsWithTenantOwnedRecords;
use ResolvesPanelTenantContext;
use ResolvesUiActionContext;
use WorkspaceScopedEnvironmentRoutes;
protected static ?string $model = RestoreRun::class;
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
public static function shouldRegisterNavigation(): bool
{
return NavigationScope::shouldRegisterEnvironmentNavigation()
&& parent::shouldRegisterNavigation();
}
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-path-rounded-square';
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
public static function canViewAny(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
}
public static function canCreate(): bool
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $resolver->isMember($user, $tenant)
&& $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
->satisfy(ActionSurfaceSlot::ListHeader, 'Create restore run is available from the list header whenever records already exist.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while rerun and archive lifecycle actions stay grouped under More.')
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk restore-run maintenance actions are grouped under More.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state exposes the New restore run CTA.')
->exempt(ActionSurfaceSlot::DetailHeader, 'View page has no additional header actions.');
}
public static function form(Schema $schema): Schema
{
return $schema
->schema([
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(fn () => static::restoreBackupSetOptions())
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set): void {
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', []);
$set('is_dry_run', true);
})
->required(),
Forms\Components\CheckboxList::make('backup_item_ids')
->label('Items to restore (optional)')
->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(fn (): string => static::restoreItemQualityHelperText()),
Section::make('Resolve target mappings')
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
);
return [
Forms\Components\ViewField::make('restore_mapping_resolver_summary_form')
->hiddenLabel()
->view('filament.forms.components.restore-run-mapping-resolver-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)),
...array_map(function (array $group) use ($tenant): Forms\Components\TextInput {
$groupId = $group['id'];
$sourceDisplayName = is_string($group['displayName'] ?? null) ? $group['displayName'] : null;
$sourceLabel = filled($sourceDisplayName) ? $sourceDisplayName : 'Unknown source group';
return Forms\Components\TextInput::make("group_mapping.{$groupId}")
->label($sourceLabel)
->placeholder('Target group Object ID (GUID)')
->rules([new SkipOrUuidRule])
->live(onBlur: true)
->helperText(fn (Get $get): HtmlString => static::groupMappingIdentityHelperText(
tenant: $tenant,
sourceGroupId: $groupId,
rawValue: $get("group_mapping.{$groupId}"),
))
->hintActions([
Actions\Action::make('skip_assignment_'.str_replace('-', '_', $groupId))
->label('Skip assignment')
->icon('heroicon-o-minus-circle')
->color('warning')
->button()
->tooltip('This assignment will not be restored.')
->visible(fn (Get $get): bool => strtoupper(trim((string) $get("group_mapping.{$groupId}"))) !== 'SKIP')
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", 'SKIP', shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
Actions\Action::make('undo_skip_assignment_'.str_replace('-', '_', $groupId))
->label('Undo skip')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->button()
->visible(fn (Get $get): bool => strtoupper(trim((string) $get("group_mapping.{$groupId}"))) === 'SKIP')
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", null, shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
])
->required()
->suffixAction(
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
->icon('heroicon-o-magnifying-glass')
->iconButton()
->tooltip('Find target group for this assignment')
->modalHeading('Resolve target group mapping')
->modalWidth('5xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Enter object ID manually')
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
'sourceGroupId' => $groupId,
'sourceGroupDisplayName' => $sourceDisplayName,
'tenantId' => (int) $tenant->getKey(),
]))
);
}, $unresolved),
];
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
tenant: $tenant
) !== [];
})
->collapsible()
->collapsed(),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
]);
}
public static function makeCreateAction(): Actions\CreateAction
{
$action = Actions\CreateAction::make()
->label('New restore run');
UiEnforcement::forScopedAction(
$action,
fn (): UiActionContext => static::tenantUiActionContext(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply();
return $action;
}
public static function getEloquentQuery(): Builder
{
return static::scopeTenantOwnedQuery(parent::getEloquentQuery())
->with(['backupSet', 'operationRun']);
}
public static function resolveScopedRecordOrFail(int|string $key): Model
{
return static::resolveTenantOwnedRecordOrFail(
$key,
parent::getEloquentQuery()->withTrashed()->with(['backupSet', 'operationRun', 'tenant']),
);
}
protected static function resolveProtectedRestoreRunRecordOrFail(RestoreRun|int|string $record): RestoreRun
{
$resolvedRecord = static::resolveScopedRecordOrFail($record instanceof Model ? $record->getKey() : $record);
if (! $resolvedRecord instanceof RestoreRun) {
abort(404);
}
return $resolvedRecord;
}
/**
* @return array<int, int>
*/
protected static function resolveProtectedRestoreRunIds(Collection $records): array
{
return $records
->map(function (mixed $record): int {
$resolvedRecord = static::resolveProtectedRestoreRunRecordOrFail($record instanceof RestoreRun ? $record : (is_numeric($record) ? (int) $record : 0));
return (int) $resolvedRecord->getKey();
})
->filter(fn (int $value): bool => $value > 0)
->unique()
->values()
->all();
}
/**
* @return array<int, Step>
*/
public static function getWizardSteps(): array
{
return [
Step::make('Select Backup Set')
->description('Select a source.')
->schema([
Forms\Components\ViewField::make('restore_safety_decision')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-decision')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1)),
Forms\Components\Select::make('backup_set_id')
->label('Backup set')
->options(fn () => static::restoreBackupSetOptions())
->helperText(fn (Get $get): string => static::restoreBackupSetHelperText($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$groupMapping = static::groupMappingPlaceholders(
backupSetId: $get('backup_set_id'),
scopeMode: 'all',
selectedItemIds: null,
tenant: static::resolveTenantContextForCurrentPanel(),
);
$set('scope_mode', 'all');
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => $groupMapping,
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->required(),
Forms\Components\ViewField::make('restore_backup_quality_summary')
->hiddenLabel()
->view('filament.forms.components.restore-run-backup-quality-summary')
->viewData(fn (Get $get): array => [
...static::restoreWizardViewData($get, currentStep: 1),
'compact' => true,
]),
Forms\Components\ViewField::make('restore_safety_evidence')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-evidence')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 1, compactFlow: true)),
]),
Step::make('Define Restore Scope')
->description('Scope and mappings.')
->afterValidation(function (Get $get, \Filament\Schemas\Components\Wizard\Step $component): void {
$contract = static::restoreWizardViewData($get, currentStep: 2, compactFlow: true);
$mappingResolver = is_array($contract['mappingResolver'] ?? null) ? $contract['mappingResolver'] : [];
if (! ($mappingResolver['requiresResolution'] ?? false)) {
return;
}
$wizard = $component->getContainer()->getParentComponent();
if ($wizard instanceof \Filament\Schemas\Components\Wizard) {
$wizard->goToStep($component->getKey());
}
Notification::make()
->title('Mappings required')
->body('Resolve required mappings before validation can run.')
->danger()
->send();
throw new Halt;
})
->schema([
Forms\Components\Radio::make('scope_mode')
->label('Scope')
->options([
'all' => 'All items (default)',
'selected' => 'Selected items only',
])
->default('all')
->reactive()
->afterStateUpdated(function (Set $set, Get $get, $state): void {
$backupSetId = $get('backup_set_id');
$tenant = static::resolveTenantContextForCurrentPanel();
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
if ($state === 'all') {
$groupMapping = static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'all',
selectedItemIds: null,
tenant: $tenant,
);
$set('backup_item_ids', null);
$set('group_mapping', $groupMapping);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'all',
'backup_item_ids' => [],
'group_mapping' => $groupMapping,
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
return;
}
$set('group_mapping', []);
$set('backup_item_ids', []);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'scope_mode' => 'selected',
'backup_item_ids' => [],
'group_mapping' => [],
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->required(),
Forms\Components\Select::make('backup_item_ids')
->label('Items to restore')
->multiple()
->searchable()
->searchValues()
->searchDebounce(400)
->optionsLimit(300)
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
->reactive()
->afterStateUpdated(function (Set $set, Get $get): void {
$backupSetId = $get('backup_set_id');
$selectedItemIds = $get('backup_item_ids');
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$tenant = static::resolveTenantContextForCurrentPanel();
$set('group_mapping', static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
));
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft([
...static::draftDataSnapshot($get),
'backup_item_ids' => $selectedItemIds ?? [],
'group_mapping' => static::groupMappingPlaceholders(
backupSetId: $backupSetId,
scopeMode: 'selected',
selectedItemIds: $selectedItemIds,
tenant: $tenant,
),
]);
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
})
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
->hintActions([
Actions\Action::make('select_all_backup_items')
->label('Select all')
->icon('heroicon-o-check')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')) && $get('scope_mode') === 'selected')
->action(function (Get $get, Set $set): void {
$groupedOptions = static::restoreItemGroupedOptions($get('backup_set_id'));
$allItemIds = [];
foreach ($groupedOptions as $options) {
$allItemIds = array_merge($allItemIds, array_keys($options));
}
$set('backup_item_ids', array_values($allItemIds), shouldCallUpdatedHooks: true);
}),
Actions\Action::make('clear_backup_items')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
->action(fn (Set $set) => $set('backup_item_ids', [], shouldCallUpdatedHooks: true)),
])
->helperText(fn (): string => static::restoreItemQualityHelperText()),
Forms\Components\ViewField::make('restore_scope_summary')
->hiddenLabel()
->view('filament.forms.components.restore-run-scope-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)),
Section::make('Resolve target mappings')
->description(function (Get $get): string {
$contract = static::restoreWizardViewData($get, currentStep: 2, compactFlow: true);
$mappingResolver = is_array($contract['mappingResolver'] ?? null) ? $contract['mappingResolver'] : [];
$resolvedCount = (int) ($mappingResolver['resolvedCount'] ?? 0);
$totalCount = (int) ($mappingResolver['totalCount'] ?? 0);
$unresolvedCount = (int) ($mappingResolver['unresolvedCount'] ?? 0);
$skippedCount = (int) ($mappingResolver['skippedCount'] ?? 0);
$manualFallbackCount = (int) ($mappingResolver['manualFallbackCount'] ?? 0);
$parts = [
"{$resolvedCount} of {$totalCount} mappings resolved",
"{$unresolvedCount} unresolved",
"{$skippedCount} skipped",
];
if ($manualFallbackCount > 0) {
$parts[] = "{$manualFallbackCount} manual fallback";
}
return implode(' · ', $parts);
})
->extraAttributes([
'data-testid' => 'restore-run-mapping-resolver-section',
])
->schema(function (Get $get): array {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [];
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
);
$rows = [];
foreach ($unresolved as $group) {
$groupId = $group['id'];
$sourceDisplayName = is_string($group['displayName'] ?? null) ? $group['displayName'] : null;
$sourceLabel = filled($sourceDisplayName) ? $sourceDisplayName : 'Unknown source group';
$safeGroupToken = str_replace('-', '_', $groupId);
$statePath = "group_mapping.{$groupId}";
$isSkipped = fn (Get $get): bool => strtoupper(trim((string) $get($statePath))) === 'SKIP';
$rows[] = Forms\Components\ViewField::make("group_mapping_skipped_{$safeGroupToken}")
->label($sourceLabel)
->view('filament.forms.components.restore-run-group-mapping-skipped')
->viewData(fn (Get $get): array => [
'identityHtml' => static::groupMappingIdentityHelperText(
tenant: $tenant,
sourceGroupId: $groupId,
rawValue: $get($statePath),
),
])
->visible($isSkipped)
->hintActions([
Actions\Action::make("undo_skip_assignment_{$safeGroupToken}")
->label('Undo skip')
->icon('heroicon-o-arrow-uturn-left')
->color('gray')
->button()
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", null, shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
]);
$rows[] = Forms\Components\TextInput::make($statePath)
->label($sourceLabel)
->placeholder('Target group Object ID (GUID)')
->rules([new SkipOrUuidRule])
->live(onBlur: true)
->afterStateUpdated(fn (Set $set, Get $get) => static::touchWizardDraftAfterGroupMappingChange($get, $set))
->helperText(fn (Get $get): HtmlString => static::groupMappingIdentityHelperText(
tenant: $tenant,
sourceGroupId: $groupId,
rawValue: $get($statePath),
))
->hintActions([
Actions\Action::make("skip_assignment_{$safeGroupToken}")
->label('Skip assignment')
->icon('heroicon-o-minus-circle')
->color('warning')
->button()
->tooltip('This assignment will not be restored.')
->action(function (Get $get, Set $set) use ($groupId): void {
$set("group_mapping.{$groupId}", 'SKIP', shouldCallUpdatedHooks: true);
static::touchWizardDraftAfterGroupMappingChange($get, $set);
}),
])
->markAsRequired()
->visible(fn (Get $get): bool => ! $isSkipped($get))
->dehydratedWhenHidden()
->suffixAction(
Actions\Action::make("select_from_directory_cache_{$safeGroupToken}")
->icon('heroicon-o-magnifying-glass')
->iconButton()
->tooltip('Find target group for this assignment')
->modalHeading('Resolve target group mapping')
->modalWidth('5xl')
->modalSubmitAction(false)
->modalCancelActionLabel('Enter object ID manually')
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
'sourceGroupId' => $groupId,
'sourceGroupDisplayName' => $sourceDisplayName,
'tenantId' => (int) $tenant->getKey(),
]))
);
}
return [
Forms\Components\ViewField::make('restore_mapping_resolver_summary')
->hiddenLabel()
->view('filament.forms.components.restore-run-mapping-resolver-summary')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 2, compactFlow: true)),
...$rows,
];
})
->visible(function (Get $get): bool {
$backupSetId = $get('backup_set_id');
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = $get('backup_item_ids');
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return false;
}
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return false;
}
return static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant
) !== [];
})
->collapsible()
->collapsed(),
Forms\Components\ViewField::make('restore_scope_evidence')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-evidence')
->viewData(fn (Get $get): array => [
...static::restoreWizardViewData($get, currentStep: 2, compactFlow: true),
'evidenceTitle' => 'Restore evidence',
'evidenceDescription' => 'Gate status and proof remain available after scope and mapping decisions without dominating the mapping work.',
'evidenceDetailsLabel' => 'View validation gates and restore proof',
]),
]),
Step::make('Safety & Conflict Checks')
->description('Validate impact.')
->afterValidation(function (Get $get, \Filament\Schemas\Components\Wizard\Step $component): void {
$contract = static::restoreWizardViewData($get, currentStep: 3, compactFlow: true);
$wizardGate = is_array($contract['wizardGate'] ?? null) ? $contract['wizardGate'] : [];
$validationSummary = is_array($contract['validationSummary'] ?? null) ? $contract['validationSummary'] : [];
$wizard = $component->getContainer()->getParentComponent();
if ((bool) ($wizardGate['can_continue'] ?? true)) {
return;
}
if ($wizard instanceof \Filament\Schemas\Components\Wizard) {
$wizard->goToStep($component->getKey());
}
$notification = Notification::make()
->title((string) ($wizardGate['next_gate_label'] ?? 'Validation blocked'))
->body((string) ($wizardGate['continue_disabled_reason'] ?? $wizardGate['required_action_label'] ?? 'Complete validation before moving to preview.'));
if (($wizardGate['next_gate'] ?? null) === 'validation_blocked') {
$notification->danger();
} else {
$notification->warning();
}
$providerConnectionsUrl = $validationSummary['providerConnectionsUrl'] ?? null;
if ((bool) ($validationSummary['providerCredentialBlocked'] ?? false) && is_string($providerConnectionsUrl) && $providerConnectionsUrl !== '') {
$notification->actions([
\Filament\Actions\Action::make('review_provider_connection')
->label('Review provider connection')
->url($providerConnectionsUrl, shouldOpenInNewTab: true)
->button(),
]);
}
$notification->send();
throw new Halt;
})
->schema([
Forms\Components\Hidden::make('scope_basis')
->default(null),
Forms\Components\Hidden::make('check_summary')
->default(null),
Forms\Components\Hidden::make('checks_ran_at')
->default(null),
Forms\Components\Hidden::make('check_basis')
->default(null),
Forms\Components\Hidden::make('check_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('check_results')
->label('Checks')
->default([])
->view('filament.forms.components.restore-run-checks')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 3, compactFlow: true))
->hintActions([
Actions\Action::make('run_restore_checks')
->label('Run checks')
->icon('heroicon-o-shield-check')
->color('gray')
->visible(function (Get $get): bool {
if (blank($get('backup_set_id'))) {
return false;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return false;
}
$resolution = app(\App\Services\Providers\ProviderConnectionResolver::class)
->resolveDefault($tenant, 'microsoft');
return $resolution->resolved;
})
->action(function (Get $get, Set $set): void {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return;
}
$providerResolution = app(\App\Services\Providers\ProviderConnectionResolver::class)
->resolveDefault($tenant, 'microsoft');
if (! $providerResolution->resolved) {
Notification::make()
->title('Validation blocked')
->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.')
->danger()
->actions([
\Filament\Actions\Action::make('review_provider_connection')
->label('Review provider connection')
->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true)
->button(),
])
->send();
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) {
Notification::make()
->title('Unable to run checks')
->body('Backup set is not available for the active environment.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$groupMapping = static::normalizeGroupMapping($get('group_mapping'));
$checker = app(RestoreRiskChecker::class);
try {
$outcome = $checker->check(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
} catch (\App\Services\Providers\ProviderConfigurationRequiredException) {
Notification::make()
->title('Validation blocked')
->body('Provider credentials are not available for this environment. Restore checks cannot run until the provider connection is repaired.')
->danger()
->actions([
\Filament\Actions\Action::make('review_provider_connection')
->label('Review provider connection')
->url(\App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant), shouldOpenInNewTab: true)
->button(),
])
->send();
return;
} catch (\Throwable) {
Notification::make()
->title('Unable to run checks')
->body('Validation could not run due to an unexpected error.')
->danger()
->send();
return;
}
$ranAt = now('UTC')->toIso8601String();
$draft = [
...static::draftDataSnapshot($get),
'check_summary' => $outcome['summary'] ?? [],
'check_results' => $outcome['results'] ?? [],
'checks_ran_at' => $ranAt,
];
$draft['check_basis'] = static::restoreSafetyResolver()->checksBasisFromData($draft);
$draft['check_invalidation_reasons'] = [];
$draft = static::synchronizeRestoreSafetyDraft($draft);
$set('check_summary', $draft['check_summary'], shouldCallUpdatedHooks: true);
$set('check_results', $draft['check_results'], shouldCallUpdatedHooks: true);
$set('checks_ran_at', $ranAt, shouldCallUpdatedHooks: true);
$set('check_basis', $draft['check_basis'], shouldCallUpdatedHooks: true);
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
$summary = $outcome['summary'] ?? [];
$blockers = (int) ($summary['blocking'] ?? 0);
$warnings = (int) ($summary['warning'] ?? 0);
if ($blockers > 0) {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
}
$bodyParts = [];
if ($blockers > 0) {
$bodyParts[] = $blockers.' '.Str::plural('blocker', $blockers);
}
if ($warnings > 0) {
$bodyParts[] = $warnings.' '.Str::plural('warning', $warnings);
}
$notificationTitle = match (true) {
$blockers > 0 => 'Safety checks finished with blockers',
$warnings > 0 => 'Safety checks finished with warnings',
default => 'Safety checks completed',
};
Notification::make()
->title($notificationTitle)
->body($bodyParts === [] ? 'No blockers or warnings' : implode(' · ', $bodyParts))
->status($blockers > 0 ? 'danger' : ($warnings > 0 ? 'warning' : 'success'))
->send();
}),
Actions\Action::make('clear_restore_checks')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('check_results')) || filled($get('check_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('check_summary', null, shouldCallUpdatedHooks: true);
$set('check_results', [], shouldCallUpdatedHooks: true);
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
$set('check_basis', null, shouldCallUpdatedHooks: true);
$set('check_invalidation_reasons', [], shouldCallUpdatedHooks: true);
}),
])
->helperText(function (Get $get): string {
if (filled($get('backup_set_id'))) {
$tenant = static::resolveTenantContextForCurrentPanel();
if ($tenant instanceof ManagedEnvironment) {
$resolution = app(\App\Services\Providers\ProviderConnectionResolver::class)
->resolveDefault($tenant, 'microsoft');
if (! $resolution->resolved) {
return 'Repair the provider connection before validation can run.';
}
}
}
$invalidationReasons = $get('check_invalidation_reasons');
if (is_array($invalidationReasons) && $invalidationReasons !== []) {
return 'Scope or mapping changed; rerun checks for the current scope before preview.';
}
if (filled($get('checks_ran_at')) || filled($get('check_results')) || filled($get('check_summary'))) {
return 'Checks are current for this scope. Rerun only after scope or mapping changes.';
}
return 'Run checks after defining scope and resolving required mappings.';
}),
Forms\Components\ViewField::make('restore_validation_evidence')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-evidence')
->viewData(fn (Get $get): array => [
...static::restoreWizardViewData($get, currentStep: 3, compactFlow: true),
'evidenceTitle' => 'Validation evidence',
'evidenceDescription' => 'Gate status and restore proof remain available after validation without repeating the validation decision.',
'evidenceDetailsLabel' => 'View validation gates and restore proof',
]),
]),
Step::make('Preview')
->description('Review changes.')
->afterValidation(function (Get $get): void {
$contract = static::restoreWizardViewData($get, currentStep: 4, compactFlow: true);
$wizardGate = is_array($contract['wizardGate'] ?? null) ? $contract['wizardGate'] : [];
if ((bool) ($wizardGate['can_continue'] ?? true)) {
return;
}
$nextGate = (string) ($wizardGate['next_gate_label'] ?? 'Preview required');
$body = (string) ($wizardGate['continue_disabled_reason'] ?? $wizardGate['required_action_label'] ?? 'Complete preview before proceeding to confirmation.');
Notification::make()
->title($nextGate)
->body($body)
->warning()
->send();
throw new Halt;
})
->schema([
Forms\Components\Hidden::make('preview_summary')
->default(null),
Forms\Components\Hidden::make('preview_ran_at')
->default(null)
->required(),
Forms\Components\Hidden::make('preview_basis')
->default(null),
Forms\Components\Hidden::make('preview_invalidation_reasons')
->default([]),
Forms\Components\ViewField::make('preview_diffs')
->label('Preview')
->default([])
->view('filament.forms.components.restore-run-preview')
->viewData(fn (Get $get): array => [
'summary' => $get('preview_summary'),
'ranAt' => $get('preview_ran_at'),
...static::restoreWizardViewData($get, currentStep: 4, compactFlow: true),
])
->hintActions([
Actions\Action::make('run_restore_preview')
->label(fn (Get $get): string => (filled($get('preview_diffs')) || filled($get('preview_summary')))
? 'Regenerate preview'
: 'Generate preview')
->icon(fn (Get $get): string => (filled($get('preview_diffs')) || filled($get('preview_summary')))
? 'heroicon-o-arrow-path'
: 'heroicon-o-eye')
->color('gray')
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
->action(function (Get $get, Set $set): void {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return;
}
$backupSetId = $get('backup_set_id');
if (! $backupSetId) {
return;
}
$backupSet = BackupSet::find($backupSetId);
if (! $backupSet || $backupSet->managed_environment_id !== $tenant->id) {
Notification::make()
->title('Unable to generate preview')
->body('Backup set is not available for the active environment.')
->danger()
->send();
return;
}
$scopeMode = $get('scope_mode') ?? 'all';
$selectedItemIds = ($scopeMode === 'selected')
? ($get('backup_item_ids') ?? null)
: null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$generator = app(RestoreDiffGenerator::class);
$outcome = $generator->generate(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
);
$summary = $outcome['summary'] ?? [];
$diffs = $outcome['diffs'] ?? [];
$ranAt = (string) ($summary['generated_at'] ?? now('UTC')->toIso8601String());
$draft = [
...static::draftDataSnapshot($get),
'preview_summary' => $summary,
'preview_diffs' => $diffs,
'preview_ran_at' => $ranAt,
];
$draft['preview_basis'] = static::restoreSafetyResolver()->previewBasisFromData($draft);
$draft['preview_invalidation_reasons'] = [];
$draft = static::synchronizeRestoreSafetyDraft($draft);
$set('preview_summary', $summary, shouldCallUpdatedHooks: true);
$set('preview_diffs', $diffs, shouldCallUpdatedHooks: true);
$set('preview_ran_at', $ranAt, shouldCallUpdatedHooks: true);
$set('preview_basis', $draft['preview_basis'], shouldCallUpdatedHooks: true);
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
$set('scope_basis', $draft['scope_basis'], shouldCallUpdatedHooks: true);
$policiesChanged = (int) ($summary['policies_changed'] ?? 0);
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
$previewBody = match (true) {
$policiesTotal <= 0 => 'No policies in scope.',
$policiesChanged <= 0 => 'No policy changes detected.',
$policiesChanged === 1 => '1 policy has preview changes.',
default => "{$policiesChanged} policies have preview changes.",
};
$previewStatus = match (true) {
$policiesTotal <= 0 => 'info',
$policiesChanged > 0 => 'warning',
default => 'success',
};
Notification::make()
->title('Preview generated')
->body($previewBody)
->status($previewStatus)
->send();
}),
Actions\Action::make('clear_restore_preview')
->label('Clear')
->icon('heroicon-o-x-mark')
->color('gray')
->visible(fn (Get $get): bool => filled($get('preview_diffs')) || filled($get('preview_summary')))
->action(function (Set $set): void {
$set('is_dry_run', true, shouldCallUpdatedHooks: true);
$set('acknowledged_impact', false, shouldCallUpdatedHooks: true);
$set('tenant_confirm', null, shouldCallUpdatedHooks: true);
$set('preview_summary', null, shouldCallUpdatedHooks: true);
$set('preview_diffs', [], shouldCallUpdatedHooks: true);
$set('preview_ran_at', null, shouldCallUpdatedHooks: true);
$set('preview_basis', null, shouldCallUpdatedHooks: true);
$set('preview_invalidation_reasons', [], shouldCallUpdatedHooks: true);
}),
])
->helperText(function (Get $get): string {
$invalidationReasons = $get('preview_invalidation_reasons');
if (is_array($invalidationReasons) && $invalidationReasons !== []) {
return 'Scope or mapping changed; regenerate the preview for the current scope before confirmation.';
}
if (filled($get('preview_ran_at')) || filled($get('preview_diffs')) || filled($get('preview_summary'))) {
return 'Preview is current for this scope. Regenerate only after scope, mapping, or source changes.';
}
return 'Generate a normalized diff preview before confirmation.';
}),
]),
Step::make('Confirm & Execute')
->description('Confirm safely.')
->schema([
Forms\Components\ViewField::make('restore_confirm_panel')
->hiddenLabel()
->view('filament.forms.components.restore-run-confirm-panel')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true)),
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true)
->reactive()
->disabled(function (Get $get): bool {
$contract = static::restoreWizardViewData($get, currentStep: 5, compactFlow: true);
$wizardGate = is_array($contract['wizardGate'] ?? null) ? $contract['wizardGate'] : [];
return ! (bool) ($wizardGate['can_execute'] ?? false);
})
->helperText(function (Get $get): string {
$contract = static::restoreWizardViewData($get, currentStep: 5, compactFlow: true);
$wizardGate = is_array($contract['wizardGate'] ?? null) ? $contract['wizardGate'] : [];
if (! (bool) ($wizardGate['can_execute'] ?? false)) {
return 'Preview-only is locked on until execution prerequisites are resolved.';
}
if ((bool) ($get('is_dry_run') ?? true)) {
return 'Creates a preview-only run. Turn OFF only when you intend to queue real execution.';
}
return 'Real execution requires impact acknowledgement and the environment label.';
}),
Forms\Components\Checkbox::make('acknowledged_impact')
->label('I reviewed the impact (checks + preview)')
->accepted()
->visible(fn (Get $get): bool => $get('is_dry_run') === false),
Forms\Components\TextInput::make('tenant_confirm')
->label('Type the environment label to confirm execution')
->required(fn (Get $get): bool => $get('is_dry_run') === false)
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
->in(function (): array {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return [];
}
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())];
})
->validationMessages([
'in' => 'Confirmation label does not match.',
])
->helperText(function (): string {
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant) {
return '';
}
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
$expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
return "Type: {$expected}";
}),
Grid::make([
'default' => 1,
'xl' => 12,
])
->schema([
Forms\Components\ViewField::make('restore_safety_status_confirm')
->hiddenLabel()
->view('filament.forms.components.restore-run-safety-gates')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 8,
]),
Forms\Components\ViewField::make('restore_proof_confirm')
->hiddenLabel()
->view('filament.forms.components.restore-run-proof-aside')
->viewData(fn (Get $get): array => static::restoreWizardViewData($get, currentStep: 5, compactFlow: true))
->columnSpan([
'default' => 1,
'xl' => 4,
]),
]),
]),
];
}
public static function table(Table $table): Table
{
return $table
->persistFiltersInSession()
->persistSearchInSession()
->persistSortInSession()
->columns([
Tables\Columns\TextColumn::make('backupSet.name')
->label('Backup set')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('is_dry_run')->label('Dry-run')->boolean(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::RestoreRunStatus))
->color(BadgeRenderer::color(BadgeDomain::RestoreRunStatus))
->icon(BadgeRenderer::icon(BadgeDomain::RestoreRunStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::RestoreRunStatus))
->sortable(),
Tables\Columns\TextColumn::make('result_attention_summary')
->label('Result attention')
->state(fn (RestoreRun $record): string => static::restoreSafetyResolver()->resultAttentionForRun($record)->summary)
->wrap(),
Tables\Columns\TextColumn::make('summary_total')
->label('Total')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['total'] ?? 0)),
Tables\Columns\TextColumn::make('summary_succeeded')
->label('Applied')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['succeeded'] ?? 0)),
Tables\Columns\TextColumn::make('summary_failed')
->label('Failed items')
->state(fn (RestoreRun $record): int => (int) (($record->metadata ?? [])['failed'] ?? 0)),
Tables\Columns\TextColumn::make('started_at')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('completed_at')->dateTime()->since()->sortable(),
Tables\Columns\TextColumn::make('requested_by')->label('Requested by')->searchable()->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(FilterOptionCatalog::restoreRunStatuses())
->searchable(),
Tables\Filters\SelectFilter::make('outcome')
->options(FilterOptionCatalog::restoreRunOutcomes())
->query(function (\Illuminate\Database\Eloquent\Builder $query, array $data): \Illuminate\Database\Eloquent\Builder {
$value = $data['value'] ?? null;
return match ((string) $value) {
'succeeded' => $query->whereIn('status', [
\App\Support\RestoreRunStatus::Previewed->value,
\App\Support\RestoreRunStatus::Completed->value,
]),
'partial' => $query->whereIn('status', [
\App\Support\RestoreRunStatus::Partial->value,
\App\Support\RestoreRunStatus::CompletedWithErrors->value,
]),
'failed' => $query->whereIn('status', [
\App\Support\RestoreRunStatus::Failed->value,
\App\Support\RestoreRunStatus::Cancelled->value,
\App\Support\RestoreRunStatus::Aborted->value,
]),
default => $query,
};
}),
FilterPresets::dateRange('started_at', 'Started', 'started_at'),
FilterPresets::archived(),
])
->recordUrl(fn (RestoreRun $record): string => static::getUrl('view', ['record' => $record]))
->actions([
ActionGroup::make([
static::rerunActionWithGate(),
UiEnforcement::forTableAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$record->restore();
if ($record->tenant) {
$auditLogger->log(
tenant: $record->tenant,
action: 'restore_run.restored',
resourceType: 'restore_run',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['backup_set_id' => $record->backup_set_id]]
);
}
Notification::make()
->title('Restore run restored')
->success()
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
if (! $record->isDeletable()) {
Notification::make()
->title('Restore run cannot be archived')
->body("Not deletable (status: {$record->status})")
->warning()
->send();
return;
}
$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();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply(),
UiEnforcement::forTableAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (RestoreRun $record): bool => $record->trashed())
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
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();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->preserveVisibility()
->apply(),
])
->label('More')
->icon('heroicon-o-ellipsis-vertical'),
])
->bulkActions([
BulkActionGroup::make([
UiEnforcement::forBulkAction(
BulkAction::make('bulk_delete')
->label('Archive Restore Runs')
->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;
})
->modalDescription('This archives restore runs (soft delete). Already archived runs will be skipped.')
->form(function (Collection $records) {
if ($records->count() >= 20) {
return [
Forms\Components\TextInput::make('confirmation')
->label('Type DELETE to confirm')
->in(['DELETE'])
->validationMessages([
'in' => 'Please type DELETE to confirm.',
]),
];
}
return [];
})
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'restore_run.delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
BulkRestoreRunDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.delete')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_restore')
->label('Restore Restore Runs')
->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()} restore runs?")
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
->action(function (Collection $records) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'restore_run.restore',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
if ($count >= 20) {
BulkRestoreRunRestoreJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkRestoreRunRestoreJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.restore')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forBulkAction(
BulkAction::make('bulk_force_delete')
->label('Force Delete Restore Runs')
->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()} restore runs?")
->modalDescription('This is permanent. Only archived restore runs will be permanently deleted; active runs 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) {
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$count = $records->count();
$ids = static::resolveProtectedRestoreRunIds($records);
if (! $tenant instanceof ManagedEnvironment) {
return;
}
$initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenant,
type: 'restore_run.force_delete',
targetScope: [
'entra_tenant_id' => (string) ($tenant->managed_environment_id ?? $tenant->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
if ($count >= 20) {
BulkRestoreRunForceDeleteJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
return;
}
BulkRestoreRunForceDeleteJob::dispatchSync(
tenantId: (int) $tenant->getKey(),
userId: (int) ($initiator?->getKey() ?? 0),
restoreRunIds: $ids,
operationRun: $operationRun,
);
},
initiator: $initiator,
extraContext: [
'restore_run_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('restore_run.force_delete')
->actions([
Actions\Action::make('view_run')
->label('Open operation')
->url(OperationRunLinks::view($opRun, $tenant)),
])
->send();
})
->deselectRecordsAfterCompletion(),
)
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
])->label('More'),
])
->emptyStateHeading('No restore runs')
->emptyStateDescription('Start a restoration from a backup set.')
->emptyStateIcon('heroicon-o-arrow-path-rounded-square')
->emptyStateActions([
static::makeCreateAction(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema
->schema([
Infolists\Components\ViewEntry::make('results')
->label('Post-execution proof')
->view('filament.infolists.entries.restore-results')
->state(fn (RestoreRun $record): array => static::detailResultsState($record))
->columnSpanFull(),
Section::make('Technical preview evidence')
->description('Secondary pre-execution context. The post-execution proof decision above is the operator-facing source of truth.')
->schema([
Infolists\Components\ViewEntry::make('preview')
->hiddenLabel()
->view('filament.infolists.entries.restore-preview')
->state(fn (RestoreRun $record): array => static::detailPreviewState($record)),
])
->collapsible()
->collapsed()
->columnSpanFull(),
Section::make('Technical record metadata')
->description('Raw lifecycle fields for audit and support. Use the proof decision above for restore outcome status.')
->schema([
Infolists\Components\TextEntry::make('backupSet.name')->label('Backup set'),
Infolists\Components\TextEntry::make('status')
->label('Record lifecycle')
->badge()
->formatStateUsing(fn ($state): string => Str::headline((string) $state))
->color('gray'),
Infolists\Components\TextEntry::make('is_dry_run')
->label('Preview-only flag')
->formatStateUsing(fn ($state) => $state ? 'Yes' : 'No')
->badge(),
Infolists\Components\TextEntry::make('requested_by')->placeholder('Not recorded'),
Infolists\Components\TextEntry::make('started_at')->dateTime()->placeholder('—'),
Infolists\Components\TextEntry::make('completed_at')->dateTime()->placeholder('—'),
])
->columns(3)
->collapsible()
->collapsed()
->columnSpanFull(),
]);
}
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 [];
}
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
return collect($types)
->firstWhere('type', $type) ?? [];
}
/**
* @return array{options: array<int, string>, descriptions: array<int, string>}
*/
private static function restoreItemOptionData(?int $backupSetId): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [
'options' => [],
'descriptions' => [],
];
}
$cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId);
return Cache::store('array')->rememberForever($cacheKey, function () use ($backupSetId, $tenant): array {
$items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('managed_environment_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', function ($policyQuery): void {
$policyQuery
->whereNull('ignored_at')
->orWhereNotNull('missing_from_provider_at');
});
})
->with(['policy:id,display_name,missing_from_provider_at,ignored_at', 'policyVersion:id,version_number,captured_at'])
->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);
$qualitySummary = static::backupItemQualitySummary($item);
$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;
$versionNumber = $item->policyVersion?->version_number;
$providerMissingNote = $item->policy?->missing_from_provider_at
? 'current state: provider missing; historical restore available'
: null;
$options[$item->id] = $displayName;
$parts = array_filter([
$category,
$typeLabel,
$platform,
'quality: '.$qualitySummary->compactSummary,
"restore: {$restore}",
$providerMissingNote,
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);
$descriptions[$item->id] = implode(' • ', $parts);
}
return [
'options' => $options,
'descriptions' => $descriptions,
];
});
}
/**
* @return array<string, array<int|string, string>>
*/
private static function restoreItemGroupedOptions(?int $backupSetId): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant || ! $backupSetId) {
return [];
}
$items = BackupItem::query()
->where('backup_set_id', $backupSetId)
->whereHas('backupSet', fn ($query) => $query->where('managed_environment_id', $tenant->getKey()))
->where(function ($query) {
$query->whereNull('policy_id')
->orWhereDoesntHave('policy')
->orWhereHas('policy', function ($policyQuery): void {
$policyQuery
->whereNull('ignored_at')
->orWhereNotNull('missing_from_provider_at');
});
})
->with(['policy:id,display_name,missing_from_provider_at,ignored_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
$category = $meta['category'] ?? 'Policies';
$categoryKey = $category === 'Foundations' ? 'zz-'.$category : $category;
$typeLabel = $meta['label'] ?? $item->policy_type;
$platform = $item->platform ?? $meta['platform'] ?? null;
$name = strtolower($item->resolvedDisplayName());
return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name);
});
$groups = [];
foreach ($items as $item) {
$meta = static::typeMeta($item->policy_type);
$typeLabel = $meta['label'] ?? $item->policy_type;
$category = $meta['category'] ?? 'Policies';
$platform = $item->platform ?? $meta['platform'] ?? 'all';
$restoreMode = $meta['restore'] ?? 'enabled';
$groupLabel = implode(' • ', array_filter([
$category,
$typeLabel,
$platform,
$restoreMode === 'preview-only' ? 'preview-only' : null,
]));
$groups[$groupLabel] ??= [];
$groups[$groupLabel][$item->id] = static::restoreItemSelectionLabel($item);
}
return $groups;
}
/**
* @return array<int, string>
*/
private static function restoreBackupSetOptions(): array
{
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
return BackupSet::query()
->where('managed_environment_id', $tenantId)
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->orderByDesc('created_at')
->get()
->mapWithKeys(fn (BackupSet $set): array => [
(int) $set->getKey() => static::restoreBackupSetSelectionLabel($set),
])
->all();
}
private static function restoreBackupSetSelectionLabel(BackupSet $set): string
{
$qualitySummary = static::backupSetQualitySummary($set);
return implode(' • ', array_filter([
$set->name,
sprintf('%d items', (int) ($set->item_count ?? 0)),
optional($set->created_at)->format('Y-m-d H:i'),
$qualitySummary->compactSummary,
]));
}
private static function restoreBackupSetHelperText(mixed $backupSetId): string
{
$default = 'Backup quality hints describe input strength only. They do not approve restore execution or prove recoverability.';
if (! is_numeric($backupSetId)) {
return $default;
}
$tenant = static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return $default;
}
$backupSet = BackupSet::query()
->where('managed_environment_id', (int) $tenant->getKey())
->with([
'items' => fn ($query) => $query->select([
'id',
'backup_set_id',
'payload',
'metadata',
'assignments',
]),
])
->find((int) $backupSetId);
if (! $backupSet instanceof BackupSet) {
return $default;
}
$summary = static::backupSetQualitySummary($backupSet);
return $summary->compactSummary.'. '.$summary->positiveClaimBoundary;
}
private static function restoreItemSelectionLabel(BackupItem $item): string
{
$summary = static::backupItemQualitySummary($item);
return implode(' • ', array_filter([
$item->resolvedDisplayName(),
$summary->compactSummary,
$item->policy?->missing_from_provider_at ? 'provider missing now' : null,
]));
}
private static function restoreItemQualityHelperText(): string
{
return 'Quality hints describe input strength before risk checks. Include foundations with policies when you need ID re-mapping context.';
}
private static function backupSetQualitySummary(BackupSet $backupSet): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->summarizeBackupSet($backupSet);
}
private static function backupItemQualitySummary(BackupItem $backupItem): \App\Support\BackupQuality\BackupQualitySummary
{
return app(BackupQualityResolver::class)->forBackupItem($backupItem);
}
private static function restoreWizardViewData(Get $get, int $currentStep, bool $compactFlow = false): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
return RestoreRunCreatePresenter::contract(
data: static::draftDataSnapshot($get),
currentStep: $currentStep,
compactFlow: $compactFlow,
tenant: $tenant instanceof ManagedEnvironment ? $tenant : null,
user: $user instanceof User ? $user : null,
);
}
/**
* @param array<string, mixed> $data
*/
public static function restoreWizardSubmitLabel(array $data): string
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$contract = RestoreRunCreatePresenter::contract(
data: $data,
currentStep: 5,
compactFlow: true,
tenant: $tenant instanceof ManagedEnvironment ? $tenant : null,
user: $user instanceof User ? $user : null,
);
return (string) data_get($contract, 'wizard_gate.primary_cta_label', 'Create preview-only run');
}
/**
* @param array<string, mixed> $data
* @return array{
* decisionCard: array<string, mixed>,
* backupQualityCard: array<string, mixed>,
* processFlow: array<string, mixed>,
* proofAside: array<string, mixed>,
* mappingResolver: array<string, mixed>,
* diagnosticsDisclosure: array<string, string>
* }
*/
private static function restoreWizardPresentationState(array $data): array
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
return RestoreRunCreatePresenter::contract(
data: $data,
currentStep: 1,
compactFlow: false,
tenant: $tenant instanceof ManagedEnvironment ? $tenant : null,
user: $user instanceof User ? $user : null,
);
}
public static function createRestoreRun(array $data): RestoreRun
{
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->isMember($user, $tenant)) {
abort(404);
}
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
abort(403);
}
/** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
if ($backupSet->managed_environment_id !== $tenant->id) {
abort(403, 'Backup set does not belong to the active environment.');
}
/** @var RestoreService $service */
$service = app(RestoreService::class);
$scopeMode = $data['scope_mode'] ?? 'all';
$selectedItemIds = ($scopeMode === 'selected') ? ($data['backup_item_ids'] ?? null) : null;
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$isDryRun = (bool) ($data['is_dry_run'] ?? true);
$groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null);
$data = static::synchronizeRestoreSafetyDraft([
...$data,
'group_mapping' => $groupMapping,
]);
$restoreSafetyResolver = static::restoreSafetyResolver();
$scopeBasis = is_array($data['scope_basis'] ?? null)
? $data['scope_basis']
: $restoreSafetyResolver->scopeBasisFromData($data);
$checkBasis = is_array($data['check_basis'] ?? null)
? $data['check_basis']
: $restoreSafetyResolver->checksBasisFromData($data);
$previewBasis = is_array($data['preview_basis'] ?? null)
? $data['preview_basis']
: $restoreSafetyResolver->previewBasisFromData($data);
$data = [
...$data,
'scope_basis' => $scopeBasis,
'check_basis' => $checkBasis,
'preview_basis' => $previewBasis,
];
$checkSummary = $data['check_summary'] ?? null;
$checkResults = $data['check_results'] ?? null;
$checksRanAt = $data['checks_ran_at'] ?? null;
$previewSummary = $data['preview_summary'] ?? null;
$previewDiffs = $data['preview_diffs'] ?? null;
$previewRanAt = $data['preview_ran_at'] ?? null;
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
if (! $isDryRun) {
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.execute');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorId: (int) $user->getKey(),
actorEmail: $user->email,
actorName: $user->name,
resourceType: 'restore_run',
context: [
'metadata' => [
'operation_type' => 'restore.execute',
'reason_code' => $e->reasonCode,
'backup_set_id' => $data['backup_set_id'] ?? null,
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
throw ValidationException::withMessages([
'backup_set_id' => $e->reasonMessage,
]);
}
$previewIntegrity = $restoreSafetyResolver->previewIntegrityFromData($data);
$checksIntegrity = $restoreSafetyResolver->checksIntegrityFromData($data);
$assessment = $restoreSafetyResolver->safetyAssessment($tenant, $user, $data);
if ($checksIntegrity->state === ChecksIntegrityState::STATE_NOT_RUN) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks before executing.',
]);
}
if ($checksIntegrity->state !== ChecksIntegrityState::STATE_CURRENT) {
throw ValidationException::withMessages([
'check_summary' => 'Run safety checks again for the current scope before executing.',
]);
}
if ($checksIntegrity->blockingCount > 0 || $assessment->state === 'blocked') {
throw ValidationException::withMessages([
'check_summary' => 'Blocking checks must be resolved before executing.',
]);
}
if ($previewIntegrity->state === PreviewIntegrityState::STATE_NOT_GENERATED) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview before executing.',
]);
}
if ($previewIntegrity->state !== PreviewIntegrityState::STATE_CURRENT) {
throw ValidationException::withMessages([
'preview_ran_at' => 'Generate preview again for the current scope before executing.',
]);
}
if (! (bool) ($data['acknowledged_impact'] ?? false)) {
throw ValidationException::withMessages([
'acknowledged_impact' => 'Please acknowledge that you reviewed the impact.',
]);
}
$tenantConfirm = $data['tenant_confirm'] ?? null;
if (! is_string($tenantConfirm) || $tenantConfirm !== $highlanderLabel) {
throw ValidationException::withMessages([
'tenant_confirm' => 'ManagedEnvironment hard-confirm does not match.',
]);
}
}
if ($isDryRun) {
$restoreRun = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
dryRun: true,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $groupMapping,
);
$metadata = $restoreRun->metadata ?? [];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
$metadata['scope_basis'] = $scopeBasis;
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$restoreRun->update(['metadata' => $metadata]);
return $restoreRun->refresh();
}
$preview = $service->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'scope_basis' => $scopeBasis,
];
if (is_array($checkSummary)) {
$metadata['check_summary'] = $checkSummary;
}
if (is_array($checkResults)) {
$metadata['check_results'] = $checkResults;
}
if (is_string($checksRanAt) && $checksRanAt !== '') {
$metadata['checks_ran_at'] = $checksRanAt;
}
if (is_array($previewSummary)) {
$metadata['preview_summary'] = $previewSummary;
}
if (is_array($previewDiffs)) {
$metadata['preview_diffs'] = $previewDiffs;
}
if (is_string($previewRanAt) && $previewRanAt !== '') {
$metadata['preview_ran_at'] = $previewRanAt;
}
if (is_array($checkBasis)) {
$metadata['check_basis'] = $checkBasis;
}
if (is_array($previewBasis)) {
$metadata['preview_basis'] = $previewBasis;
}
$metadata['execution_safety_snapshot'] = $restoreSafetyResolver
->executionSafetySnapshot($tenant, $user, $data)
->toArray();
try {
[$result, $restoreRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
throw new \Filament\Support\Exceptions\Halt;
}
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if (! in_array($result->status, ['started', 'deduped'], true)) {
throw new \Filament\Support\Exceptions\Halt;
}
if (! $restoreRun instanceof RestoreRun) {
throw new \RuntimeException('Restore execution was accepted without creating a restore run.');
}
return $restoreRun;
}
/**
* @param array<int>|null $selectedItemIds
* @param array<string, mixed> $preview
* @param array<string, mixed> $metadata
* @param array<string, mixed> $groupMapping
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
*/
private static function startQueuedRestoreExecution(
ManagedEnvironment $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
array $preview,
array $metadata,
array $groupMapping,
?string $actorEmail,
?string $actorName,
): array {
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
tenantId: (int) $tenant->getKey(),
backupSetId: (int) $backupSet->getKey(),
selectedItemIds: $selectedItemIds,
groupMapping: $groupMapping,
);
$initiator = auth()->user();
$initiator = $initiator instanceof User ? $initiator : null;
static::guardRestoreExecutionOperationalControl(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
initiator: $initiator,
);
$queuedRestoreRun = null;
$dispatcher = function (OperationRun $run) use (
$tenant,
$backupSet,
$selectedItemIds,
$preview,
$metadata,
$groupMapping,
$actorEmail,
$actorName,
$idempotencyKey,
&$queuedRestoreRun,
): void {
$queuedRestoreRun = RestoreRun::create([
'managed_environment_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'operation_run_id' => $run->getKey(),
'requested_by' => $actorEmail,
'is_dry_run' => false,
'status' => RestoreRunStatus::Queued->value,
'idempotency_key' => $idempotencyKey,
'requested_items' => $selectedItemIds,
'preview' => $preview,
'metadata' => $metadata,
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
]);
$context = is_array($run->context) ? $run->context : [];
$context['restore_run_id'] = (int) $queuedRestoreRun->getKey();
$run->forceFill(['context' => $context])->save();
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'restore.queued',
context: [
'metadata' => [
'restore_run_id' => $queuedRestoreRun->id,
'backup_set_id' => $backupSet->id,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'restore_run',
resourceId: (string) $queuedRestoreRun->id,
status: 'success',
);
$providerConnectionId = is_numeric($context['provider_connection_id'] ?? null)
? (int) $context['provider_connection_id']
: null;
ExecuteRestoreRunJob::dispatch(
restoreRunId: (int) $queuedRestoreRun->getKey(),
actorEmail: $actorEmail,
actorName: $actorName,
operationRun: $run,
providerConnectionId: $providerConnectionId,
)->afterCommit();
};
if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: null,
operationType: 'restore.execute',
dispatcher: $dispatcher,
initiator: $initiator,
extraContext: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
],
);
} else {
$run = app(OperationRunService::class)->ensureRunWithIdentity(
tenant: $tenant,
type: 'restore.execute',
identityInputs: [
'idempotency_key' => $idempotencyKey,
],
context: [
'backup_set_id' => (int) $backupSet->getKey(),
'is_dry_run' => false,
'execution_authority_mode' => 'actor_bound',
'required_capability' => Capabilities::TENANT_MANAGE,
'target_scope' => [
'entra_tenant_id' => $tenant->graphTenantId(),
],
],
initiator: $initiator,
);
if ($run->wasRecentlyCreated) {
$dispatcher($run);
$result = ProviderOperationStartResult::started($run, true);
} else {
$result = ProviderOperationStartResult::deduped($run);
}
}
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') {
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
if (is_numeric($restoreRunId)) {
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first();
}
$queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun(
(int) $tenant->getKey(),
$idempotencyKey,
);
}
return [$result, $queuedRestoreRun?->refresh()];
}
/**
* @param array<int>|null $selectedItemIds
*/
private static function guardRestoreExecutionOperationalControl(
ManagedEnvironment $tenant,
BackupSet $backupSet,
?array $selectedItemIds,
?User $initiator,
): void {
$workspace = $tenant->workspace;
if (! $workspace instanceof Workspace) {
throw new \RuntimeException('Restore execution requires a workspace context.');
}
$decision = app(OperationalControlEvaluator::class)->evaluate('restore.execute', $workspace);
if (! $decision->isPaused()) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $workspace,
action: AuditActionId::OperationalControlExecutionBlocked,
context: [
'metadata' => array_filter([
'control_key' => $decision->controlKey,
'scope_type' => $decision->matchedScopeType,
'workspace_id' => (int) $workspace->getKey(),
'reason_text' => $decision->reasonText,
'expires_at' => $decision->expiresAt?->toIso8601String(),
'actor_id' => $initiator?->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'selected_item_count' => is_array($selectedItemIds) ? count($selectedItemIds) : null,
'requested_scope' => 'restore.execute',
], static fn (mixed $value): bool => $value !== null && $value !== ''),
],
actor: $initiator,
status: 'blocked',
resourceType: 'operational_control',
resourceId: $decision->sourceActivationId !== null ? (string) $decision->sourceActivationId : null,
targetLabel: OperationCatalog::label('restore.execute'),
summary: 'Restore execution blocked by operational control',
tenant: $tenant,
);
throw OperationalControlBlockedException::forDecision(
decision: $decision,
actionLabel: OperationCatalog::label('restore.execute'),
);
}
/**
* @param array<int>|null $selectedItemIds
*/
private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
{
$query = $backupSet->items()->select(['id', 'policy_type']);
if (is_array($selectedItemIds) && $selectedItemIds !== []) {
$query->whereIn('id', $selectedItemIds);
}
return $query->get()->contains(function (BackupItem $item): bool {
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
return $restoreMode !== 'preview-only';
});
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public static function synchronizeRestoreSafetyDraft(array $data): array
{
$resolver = static::restoreSafetyResolver();
$scope = $resolver->scopeFingerprintFromData($data);
$data['scope_basis'] = $resolver->scopeBasisFromData($data);
$data['check_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
currentScope: $scope,
basis: is_array($data['check_basis'] ?? null) ? $data['check_basis'] : null,
explicitReasons: $data['check_invalidation_reasons'] ?? null,
);
$data['preview_invalidation_reasons'] = $resolver->invalidationReasonsForBasis(
currentScope: $scope,
basis: is_array($data['preview_basis'] ?? null) ? $data['preview_basis'] : null,
explicitReasons: $data['preview_invalidation_reasons'] ?? null,
);
return $data;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private static function wizardSafetyState(array $data): array
{
$data = static::synchronizeRestoreSafetyDraft($data);
$resolver = static::restoreSafetyResolver();
$tenant = static::resolveTenantContextForCurrentPanel();
$user = auth()->user();
$scope = $resolver->scopeFingerprintFromData($data)->toArray();
$previewIntegrity = $resolver->previewIntegrityFromData($data);
$checksIntegrity = $resolver->checksIntegrityFromData($data);
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User) {
return [
'currentScope' => $scope,
'previewIntegrity' => $previewIntegrity->toArray(),
'checksIntegrity' => $checksIntegrity->toArray(),
'executionReadiness' => null,
'safetyAssessment' => null,
'providerConnectionsUrl' => null,
];
}
$assessment = $resolver->safetyAssessment($tenant, $user, $data);
$providerResolution = app(ProviderConnectionResolver::class)->resolveDefault($tenant, 'microsoft');
return [
'currentScope' => $scope,
'previewIntegrity' => $previewIntegrity->toArray(),
'checksIntegrity' => $checksIntegrity->toArray(),
'executionReadiness' => $assessment->executionReadiness->toArray(),
'safetyAssessment' => $assessment->toArray(),
'providerResolution' => [
'resolved' => $providerResolution->resolved,
'reasonCode' => $providerResolution->effectiveReasonCode(),
],
'providerConnectionsUrl' => \App\Support\ManagedEnvironmentLinks::providerConnectionsUrl($tenant),
];
}
/**
* @return array<string, mixed>
*/
private static function draftDataSnapshot(Get $get): array
{
return [
'backup_set_id' => $get('backup_set_id'),
'scope_mode' => $get('scope_mode'),
'backup_item_ids' => $get('backup_item_ids'),
'group_mapping' => static::normalizeGroupMapping($get('group_mapping')),
'check_summary' => $get('check_summary'),
'check_results' => $get('check_results'),
'checks_ran_at' => $get('checks_ran_at'),
'check_basis' => $get('check_basis'),
'check_invalidation_reasons' => $get('check_invalidation_reasons'),
'preview_summary' => $get('preview_summary'),
'preview_diffs' => $get('preview_diffs'),
'preview_ran_at' => $get('preview_ran_at'),
'preview_basis' => $get('preview_basis'),
'preview_invalidation_reasons' => $get('preview_invalidation_reasons'),
'scope_basis' => $get('scope_basis'),
'is_dry_run' => $get('is_dry_run'),
'acknowledged_impact' => $get('acknowledged_impact'),
'tenant_confirm' => $get('tenant_confirm'),
];
}
/**
* @return array{
* preview: array<int|string, mixed>,
* previewIntegrity: array<string, mixed>,
* checksIntegrity: array<string, mixed>,
* executionSafetySnapshot: array<string, mixed>,
* scopeBasis: array<string, mixed>
* }
*/
private static function detailPreviewState(RestoreRun $record): array
{
$resolver = static::restoreSafetyResolver();
$data = [
'backup_set_id' => $record->backup_set_id,
'scope_mode' => (string) (($record->scopeBasis()['scope_mode'] ?? null) ?: ((is_array($record->requested_items) && $record->requested_items !== []) ? 'selected' : 'all')),
'backup_item_ids' => is_array($record->requested_items) ? $record->requested_items : [],
'group_mapping' => is_array($record->group_mapping) ? $record->group_mapping : [],
'preview_basis' => $record->previewBasis(),
'check_basis' => $record->checkBasis(),
'check_summary' => is_array(($record->metadata ?? [])['check_summary'] ?? null) ? $record->metadata['check_summary'] : [],
'checks_ran_at' => $record->checkBasis()['ran_at'] ?? (($record->metadata ?? [])['checks_ran_at'] ?? null),
'preview_summary' => is_array(($record->metadata ?? [])['preview_summary'] ?? null) ? $record->metadata['preview_summary'] : [],
'preview_ran_at' => $record->previewBasis()['generated_at'] ?? (($record->metadata ?? [])['preview_ran_at'] ?? null),
];
return [
'preview' => is_array($record->preview) ? $record->preview : [],
'previewIntegrity' => $resolver->previewIntegrityFromData($data)->toArray(),
'checksIntegrity' => $resolver->checksIntegrityFromData($data)->toArray(),
'executionSafetySnapshot' => $record->executionSafetySnapshot(),
'scopeBasis' => $record->scopeBasis(),
];
}
/**
* @return array{
* results: array<string, mixed>|array<int|string, mixed>,
* resultAttention: array<string, mixed>,
* executionSafetySnapshot: array<string, mixed>
* }
*/
private static function detailResultsState(RestoreRun $record): array
{
return app(RestoreRunDetailPresenter::class)->forRun($record);
}
private static function restoreSafetyResolver(): RestoreSafetyResolver
{
return app(RestoreSafetyResolver::class);
}
/**
* @param array<int>|null $selectedItemIds
* @return array<int, array{id:string,displayName:?string}>
*/
private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, ManagedEnvironment $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 [];
}
$resolver = app(EntraGroupLabelResolver::class);
$descriptions = $resolver->describeMany($tenant, $groupIds, $sourceNames);
$unresolved = [];
foreach ($groupIds as $groupId) {
$description = $descriptions[$groupId] ?? null;
if (is_array($description) && (bool) ($description['resolved'] ?? false)) {
continue;
}
$displayName = is_array($description) ? ($description['display_name'] ?? null) : null;
$unresolved[] = [
'id' => $groupId,
'displayName' => is_string($displayName) && $displayName !== '' ? $displayName : null,
];
}
return $unresolved;
}
private static function groupMappingIdentityHelperText(?ManagedEnvironment $tenant, string $sourceGroupId, mixed $rawValue): HtmlString
{
$value = is_string($rawValue) ? trim($rawValue) : '';
$renderObjectIdDetails = static function (array $items): string {
$rows = collect($items)
->map(fn (string $value, string $label): string => '<div><span class="font-medium">'.e($label).':</span> <code>'.e($value).'</code></div>')
->implode('');
return '<details class="mt-1"><summary class="cursor-pointer font-medium">View object IDs</summary><div class="mt-1 space-y-1">'.$rows.'</div></details>';
};
$sourceDetails = [
'Source ID' => $sourceGroupId,
];
if ($value === '') {
return new HtmlString(
e('Target required before validation. Skip assignment only when this source assignment should not be restored.')
.$renderObjectIdDetails($sourceDetails)
);
}
if (strtoupper($value) === 'SKIP') {
return new HtmlString(
e('Assignment skipped. This assignment will not be restored.')
.$renderObjectIdDetails($sourceDetails)
);
}
if (! Str::isUuid($value)) {
return new HtmlString(
e('Invalid group object ID (GUID).')
.$renderObjectIdDetails($sourceDetails)
);
}
$normalizedTargetId = strtolower($value);
$targetDisplayName = null;
if ($tenant instanceof ManagedEnvironment) {
$targetDisplayName = EntraGroup::query()
->where('managed_environment_id', $tenant->getKey())
->where('entra_id', $normalizedTargetId)
->value('display_name');
}
if (is_string($targetDisplayName) && $targetDisplayName !== '') {
return new HtmlString(
e('Target group: '.$targetDisplayName)
.$renderObjectIdDetails([
...$sourceDetails,
'Target ID' => $value,
])
);
}
return new HtmlString(
e('Manual fallback target object ID. Badge: Manual fallback')
.$renderObjectIdDetails([
...$sourceDetails,
'Target ID' => $value,
])
);
}
private static function touchWizardDraftAfterGroupMappingChange(Get $get, Set $set): void
{
$set('is_dry_run', true);
$set('acknowledged_impact', false);
$set('tenant_confirm', null);
$draft = static::synchronizeRestoreSafetyDraft(static::draftDataSnapshot($get));
$set('scope_basis', $draft['scope_basis']);
$set('check_invalidation_reasons', $draft['check_invalidation_reasons']);
$set('preview_invalidation_reasons', $draft['preview_invalidation_reasons']);
}
/**
* @param array<int>|null $selectedItemIds
* @return array<string, string|null>
*/
private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?ManagedEnvironment $tenant): array
{
if (! $tenant || ! $backupSetId) {
return [];
}
if ($scopeMode === 'selected' && ($selectedItemIds === null || $selectedItemIds === [])) {
return [];
}
$unresolved = static::unresolvedGroups(
backupSetId: $backupSetId,
selectedItemIds: $scopeMode === 'selected' ? $selectedItemIds : null,
tenant: $tenant,
);
$placeholders = [];
foreach ($unresolved as $group) {
$groupId = $group['id'] ?? null;
if (! is_string($groupId) || $groupId === '') {
continue;
}
$placeholders[$groupId] = null;
}
return $placeholders;
}
/**
* @return array<string, string>
*/
private static function normalizeGroupMapping(mixed $mapping): array
{
if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) {
$mapping = $mapping->toArray();
}
if ($mapping instanceof \stdClass) {
$mapping = (array) $mapping;
}
if (! is_array($mapping)) {
return [];
}
$result = [];
if (array_key_exists('group_mapping', $mapping)) {
$nested = $mapping['group_mapping'];
if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) {
$nested = $nested->toArray();
}
if ($nested instanceof \stdClass) {
$nested = (array) $nested;
}
if (is_array($nested)) {
$mapping = $nested;
}
}
foreach ($mapping as $key => $value) {
if (! is_string($key) || $key === '') {
continue;
}
$sourceGroupId = str_starts_with($key, 'group_mapping.')
? substr($key, strlen('group_mapping.'))
: $key;
if ($sourceGroupId === '') {
continue;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if (is_array($value) || $value instanceof \stdClass) {
$value = (array) $value;
$value = $value['value'] ?? $value['id'] ?? null;
}
if (is_string($value)) {
$value = trim($value);
$result[$sourceGroupId] = $value !== '' ? $value : null;
continue;
}
$result[$sourceGroupId] = null;
}
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
}
/**
* Build the rerun table action with UiEnforcement + write gate disabled state.
*
* UiEnforcement::apply() overrides ->disabled() and ->tooltip(), so the gate
* check must compose on top of the enforcement action AFTER apply(). This method
* extracts the rerun action into its own builder to keep the table definition clean.
*/
private static function rerunActionWithGate(): Actions\Action|BulkAction
{
/** @var Actions\Action $action */
$action = UiEnforcement::forTableAction(
Actions\Action::make('rerun')
->label('Rerun')
->icon('heroicon-o-arrow-path')
->color('primary')
->requiresConfirmation()
->visible(function (RestoreRun $record): bool {
$backupSet = $record->backupSet;
return ! $record->trashed()
&& $record->isDeletable()
&& $backupSet !== null
&& ! $backupSet->trashed();
})
->action(function (
RestoreRun $record,
RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire
) {
$record = static::resolveProtectedRestoreRunRecordOrFail($record);
$tenant = $record->tenant;
$backupSet = $record->backupSet;
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
Notification::make()
->title('Restore run cannot be rerun')
->body('Restore run or backup set is archived or unavailable.')
->warning()
->send();
return;
}
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
app(\App\Services\Intune\AuditLogger::class)->log(
tenant: $tenant,
action: 'intune_rbac.write_blocked',
status: 'blocked',
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
resourceType: 'restore_run',
resourceId: (string) $record->getKey(),
context: [
'metadata' => [
'operation_type' => 'restore.rerun',
'reason_code' => $e->reasonCode,
'backup_set_id' => $backupSet?->getKey(),
'original_restore_run_id' => $record->getKey(),
],
],
);
Notification::make()
->title('Write operation blocked')
->body($e->reasonMessage)
->danger()
->send();
return;
}
if (! (bool) $record->is_dry_run) {
$selectedItemIds = is_array($record->requested_items) ? $record->requested_items : null;
$groupMapping = is_array($record->group_mapping) ? $record->group_mapping : [];
$actorEmail = auth()->user()?->email;
$actorName = auth()->user()?->name;
$tenantIdentifier = $tenant->managed_environment_id ?? $tenant->external_id;
$highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey());
$preview = $restoreService->preview($tenant, $backupSet, $selectedItemIds);
$metadata = [
'scope_mode' => $selectedItemIds === null ? 'all' : 'selected',
'environment' => app()->environment('production') ? 'prod' : 'test',
'highlander_label' => $highlanderLabel,
'confirmed_at' => now()->toIso8601String(),
'confirmed_by' => $actorEmail,
'confirmed_by_name' => $actorName,
'rerun_of_restore_run_id' => $record->id,
];
$metadata['rerun_of_restore_run_id'] = $record->id;
try {
[$result, $newRun] = static::startQueuedRestoreExecution(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: $selectedItemIds,
preview: $preview,
metadata: $metadata,
groupMapping: $groupMapping,
actorEmail: $actorEmail,
actorName: $actorName,
);
} catch (OperationalControlBlockedException $exception) {
Notification::make()
->title($exception->title())
->body($exception->getMessage())
->warning()
->send();
return;
}
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
}
app(ProviderOperationStartResultPresenter::class)
->notification(
result: $result,
blockedTitle: 'Restore execution blocked',
runUrl: OperationRunLinks::view($result->run, $tenant),
)
->send();
if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) {
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,
],
],
actorEmail: $actorEmail,
actorName: $actorName,
);
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,
],
]
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast('restore.execute')
->send();
}),
fn () => static::resolveTenantContextForCurrentPanel(),
)
->requireCapability(Capabilities::TENANT_MANAGE)
->preserveVisibility()
->apply();
// Compose write gate disabled/tooltip on top of UiEnforcement's RBAC check.
// UiEnforcement::apply() sets its own ->disabled() / ->tooltip();
// we override here to merge both concerns.
$action->disabled(function (?Model $record = null): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return true;
}
// Check RBAC capability first (mirrors UiEnforcement logic)
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return true;
}
// Then check write gate
return app(WriteGateInterface::class)->wouldBlock($tenant);
});
$action->tooltip(function (?Model $record = null): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
if (! $tenant instanceof ManagedEnvironment) {
return 'ManagedEnvironment unavailable';
}
// Check RBAC capability first
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) {
return \App\Support\Auth\UiTooltips::insufficientPermission();
}
// Then check write gate
try {
app(WriteGateInterface::class)->evaluate($tenant, 'restore.rerun');
} catch (ProviderAccessHardeningRequired $e) {
return $e->reasonMessage;
}
return null;
});
return $action;
}
}