feat: add restore risk checks
This commit is contained in:
parent
8ba2aae82e
commit
f32fdfb1e4
@ -13,6 +13,7 @@
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Intune\RestoreRiskChecker;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -176,6 +177,9 @@ public static function getWizardSteps(): array
|
||||
$set('backup_item_ids', null);
|
||||
$set('group_mapping', []);
|
||||
$set('is_dry_run', true);
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
})
|
||||
->required(),
|
||||
]),
|
||||
@ -192,6 +196,9 @@ public static function getWizardSteps(): array
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set, $state): void {
|
||||
$set('group_mapping', []);
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
|
||||
if ($state === 'all') {
|
||||
$set('backup_item_ids', null);
|
||||
@ -211,7 +218,12 @@ public static function getWizardSteps(): array
|
||||
->optionsLimit(300)
|
||||
->options(fn (Get $get) => static::restoreItemGroupedOptions($get('backup_set_id')))
|
||||
->reactive()
|
||||
->afterStateUpdated(fn (Set $set) => $set('group_mapping', []))
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('group_mapping', []);
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
})
|
||||
->visible(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||
->required(fn (Get $get): bool => $get('scope_mode') === 'selected')
|
||||
->hintActions([
|
||||
@ -275,6 +287,12 @@ public static function getWizardSteps(): array
|
||||
->searchable()
|
||||
->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search))
|
||||
->getOptionLabelUsing(fn (?string $value) => static::resolveTargetGroupLabel($tenant, $value))
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set): void {
|
||||
$set('check_summary', null);
|
||||
$set('check_results', []);
|
||||
$set('checks_ran_at', null);
|
||||
})
|
||||
->helperText('Choose a target group or select Skip.');
|
||||
}, $unresolved);
|
||||
})
|
||||
@ -302,11 +320,98 @@ public static function getWizardSteps(): array
|
||||
}),
|
||||
]),
|
||||
Step::make('Safety & Conflict Checks')
|
||||
->description('Defensive checks (Phase 4)')
|
||||
->description('Is this dangerous?')
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('safety_checks_placeholder')
|
||||
->label('Status')
|
||||
->content('Safety & conflict checks will be added in Phase 4.'),
|
||||
Forms\Components\Hidden::make('check_summary')
|
||||
->default(null),
|
||||
Forms\Components\Hidden::make('checks_ran_at')
|
||||
->default(null),
|
||||
Forms\Components\ViewField::make('check_results')
|
||||
->label('Checks')
|
||||
->default([])
|
||||
->view('filament.forms.components.restore-run-checks')
|
||||
->viewData(fn (Get $get): array => [
|
||||
'summary' => $get('check_summary'),
|
||||
'ranAt' => $get('checks_ran_at'),
|
||||
])
|
||||
->hintActions([
|
||||
Actions\Action::make('run_restore_checks')
|
||||
->label('Run checks')
|
||||
->icon('heroicon-o-shield-check')
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||
->action(function (Get $get, Set $set): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSetId = $get('backup_set_id');
|
||||
|
||||
if (! $backupSetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet = BackupSet::find($backupSetId);
|
||||
|
||||
if (! $backupSet || $backupSet->tenant_id !== $tenant->id) {
|
||||
Notification::make()
|
||||
->title('Unable to run checks')
|
||||
->body('Backup set is not available for the active tenant.')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scopeMode = $get('scope_mode') ?? 'all';
|
||||
$selectedItemIds = ($scopeMode === 'selected')
|
||||
? ($get('backup_item_ids') ?? null)
|
||||
: null;
|
||||
|
||||
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||
|
||||
$groupMapping = $get('group_mapping') ?? [];
|
||||
$groupMapping = is_array($groupMapping) ? $groupMapping : [];
|
||||
$groupMapping = collect($groupMapping)
|
||||
->map(fn ($value) => is_string($value) ? $value : null)
|
||||
->all();
|
||||
|
||||
$checker = app(RestoreRiskChecker::class);
|
||||
$outcome = $checker->check(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: $selectedItemIds,
|
||||
groupMapping: $groupMapping,
|
||||
);
|
||||
|
||||
$set('check_summary', $outcome['summary'] ?? [], shouldCallUpdatedHooks: true);
|
||||
$set('check_results', $outcome['results'] ?? [], shouldCallUpdatedHooks: true);
|
||||
$set('checks_ran_at', now()->toIso8601String(), shouldCallUpdatedHooks: true);
|
||||
|
||||
$summary = $outcome['summary'] ?? [];
|
||||
$blockers = (int) ($summary['blocking'] ?? 0);
|
||||
$warnings = (int) ($summary['warning'] ?? 0);
|
||||
|
||||
Notification::make()
|
||||
->title('Safety checks completed')
|
||||
->body("Blocking: {$blockers} • Warnings: {$warnings}")
|
||||
->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('check_summary', null, shouldCallUpdatedHooks: true);
|
||||
$set('check_results', [], shouldCallUpdatedHooks: true);
|
||||
$set('checks_ran_at', null, shouldCallUpdatedHooks: true);
|
||||
}),
|
||||
])
|
||||
->helperText('Run checks after defining scope and mapping missing groups.'),
|
||||
]),
|
||||
Step::make('Preview')
|
||||
->description('Dry-run preview (Phase 5)')
|
||||
@ -858,7 +963,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
? ($data['backup_item_ids'] ?? null)
|
||||
: null;
|
||||
|
||||
return $service->execute(
|
||||
$restoreRun = $service->execute(
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null,
|
||||
@ -867,6 +972,32 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
actorName: auth()->user()?->name,
|
||||
groupMapping: $data['group_mapping'] ?? [],
|
||||
);
|
||||
|
||||
$checkSummary = $data['check_summary'] ?? null;
|
||||
$checkResults = $data['check_results'] ?? null;
|
||||
$checksRanAt = $data['checks_ran_at'] ?? null;
|
||||
|
||||
if (is_array($checkSummary) || is_array($checkResults) || (is_string($checksRanAt) && $checksRanAt !== '')) {
|
||||
$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;
|
||||
}
|
||||
|
||||
$restoreRun->update([
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
|
||||
return $restoreRun->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
608
app/Services/Intune/RestoreRiskChecker.php
Normal file
608
app/Services/Intune/RestoreRiskChecker.php
Normal file
@ -0,0 +1,608 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Intune;
|
||||
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RestoreRiskChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupResolver $groupResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $selectedItemIds
|
||||
* @param array<string, string|null> $groupMapping
|
||||
* @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array<int, array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}>}
|
||||
*/
|
||||
public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null, array $groupMapping = []): array
|
||||
{
|
||||
if ($backupSet->tenant_id !== $tenant->id) {
|
||||
throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.');
|
||||
}
|
||||
|
||||
$items = $this->loadItems($backupSet, $selectedItemIds);
|
||||
|
||||
$policyItems = $items
|
||||
->reject(fn (BackupItem $item): bool => $item->isFoundation())
|
||||
->values();
|
||||
|
||||
$results = [];
|
||||
|
||||
$results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping);
|
||||
$results[] = $this->checkPreviewOnlyPolicies($policyItems);
|
||||
$results[] = $this->checkMissingPolicies($tenant, $policyItems);
|
||||
$results[] = $this->checkStalePolicies($tenant, $policyItems);
|
||||
$results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null);
|
||||
|
||||
$results = array_values(array_filter($results));
|
||||
|
||||
$summary = [
|
||||
'blocking' => 0,
|
||||
'warning' => 0,
|
||||
'safe' => 0,
|
||||
'has_blockers' => false,
|
||||
];
|
||||
|
||||
foreach ($results as $result) {
|
||||
$severity = $result['severity'] ?? 'safe';
|
||||
|
||||
if (! in_array($severity, ['blocking', 'warning', 'safe'], true)) {
|
||||
$severity = 'safe';
|
||||
}
|
||||
|
||||
$summary[$severity]++;
|
||||
}
|
||||
|
||||
$summary['has_blockers'] = $summary['blocking'] > 0;
|
||||
|
||||
return [
|
||||
'summary' => $summary,
|
||||
'results' => $results,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int>|null $selectedItemIds
|
||||
* @return Collection<int, BackupItem>
|
||||
*/
|
||||
private function loadItems(BackupSet $backupSet, ?array $selectedItemIds): Collection
|
||||
{
|
||||
$query = $backupSet->items()->getQuery();
|
||||
|
||||
if ($selectedItemIds !== null) {
|
||||
$query->whereIn('id', $selectedItemIds);
|
||||
}
|
||||
|
||||
return $query->orderBy('id')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @param array<string, string|null> $groupMapping
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkOrphanedGroups(Tenant $tenant, Collection $policyItems, array $groupMapping): ?array
|
||||
{
|
||||
[$groupIds, $sourceNames] = $this->extractGroupIds($policyItems);
|
||||
|
||||
if ($groupIds === []) {
|
||||
return [
|
||||
'code' => 'assignment_groups',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Assignments',
|
||||
'message' => 'No group-based assignments detected.',
|
||||
'meta' => [
|
||||
'group_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$graphOptions = $tenant->graphOptions();
|
||||
$tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey();
|
||||
$resolved = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions);
|
||||
|
||||
$orphaned = [];
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$group = $resolved[$groupId] ?? null;
|
||||
|
||||
if (! is_array($group) || ! ($group['orphaned'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$orphaned[] = [
|
||||
'id' => $groupId,
|
||||
'label' => $this->formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
|
||||
];
|
||||
}
|
||||
|
||||
if ($orphaned === []) {
|
||||
return [
|
||||
'code' => 'assignment_groups',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Assignments',
|
||||
'message' => sprintf('%d group assignment targets resolved.', count($groupIds)),
|
||||
'meta' => [
|
||||
'group_count' => count($groupIds),
|
||||
'orphaned_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$unmapped = [];
|
||||
$mapped = [];
|
||||
$skipped = [];
|
||||
|
||||
foreach ($orphaned as $group) {
|
||||
$groupId = $group['id'];
|
||||
$mapping = $groupMapping[$groupId] ?? null;
|
||||
|
||||
if (! is_string($mapping) || $mapping === '') {
|
||||
$unmapped[] = $group;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($mapping === 'SKIP') {
|
||||
$skipped[] = $group;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped[] = $group + [
|
||||
'mapped_to' => $mapping,
|
||||
];
|
||||
}
|
||||
|
||||
$severity = $unmapped !== [] ? 'blocking' : 'warning';
|
||||
|
||||
$message = $unmapped !== []
|
||||
? sprintf('%d group assignment targets are missing in the tenant and require mapping (or skip).', count($unmapped))
|
||||
: sprintf('%d group assignment targets are missing in the tenant (mapped/skipped).', count($orphaned));
|
||||
|
||||
return [
|
||||
'code' => 'assignment_groups',
|
||||
'severity' => $severity,
|
||||
'title' => 'Assignments',
|
||||
'message' => $message,
|
||||
'meta' => [
|
||||
'group_count' => count($groupIds),
|
||||
'orphaned_count' => count($orphaned),
|
||||
'unmapped' => $unmapped,
|
||||
'mapped' => $mapped,
|
||||
'skipped' => $skipped,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkPreviewOnlyPolicies(Collection $policyItems): ?array
|
||||
{
|
||||
$byType = [];
|
||||
|
||||
foreach ($policyItems as $item) {
|
||||
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||
|
||||
if ($restoreMode !== 'preview-only') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = $this->resolveTypeLabel($item->policy_type);
|
||||
$byType[$label] ??= 0;
|
||||
$byType[$label]++;
|
||||
}
|
||||
|
||||
if ($byType === []) {
|
||||
return [
|
||||
'code' => 'preview_only',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Preview-only types',
|
||||
'message' => 'No preview-only policy types detected.',
|
||||
'meta' => [
|
||||
'count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'preview_only',
|
||||
'severity' => 'warning',
|
||||
'title' => 'Preview-only types',
|
||||
'message' => 'Some selected items are preview-only and will never execute.',
|
||||
'meta' => [
|
||||
'count' => array_sum($byType),
|
||||
'types' => $byType,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkMissingPolicies(Tenant $tenant, Collection $policyItems): ?array
|
||||
{
|
||||
$pairs = [];
|
||||
|
||||
foreach ($policyItems as $item) {
|
||||
$identifier = $item->policy_identifier;
|
||||
$type = $item->policy_type;
|
||||
|
||||
if (! is_string($identifier) || $identifier === '' || ! is_string($type) || $type === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pairs[] = [
|
||||
'identifier' => $identifier,
|
||||
'type' => $type,
|
||||
'label' => $item->resolvedDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($pairs === []) {
|
||||
return [
|
||||
'code' => 'missing_policies',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Target policies',
|
||||
'message' => 'No policy identifiers available to verify.',
|
||||
'meta' => [
|
||||
'missing_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$identifiers = array_values(array_unique(array_column($pairs, 'identifier')));
|
||||
$types = array_values(array_unique(array_column($pairs, 'type')));
|
||||
|
||||
$existing = Policy::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('external_id', $identifiers)
|
||||
->whereIn('policy_type', $types)
|
||||
->get(['id', 'external_id', 'policy_type'])
|
||||
->mapWithKeys(fn (Policy $policy) => [$this->policyKey($policy->policy_type, $policy->external_id) => $policy->id])
|
||||
->all();
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($pairs as $pair) {
|
||||
$key = $this->policyKey($pair['type'], $pair['identifier']);
|
||||
|
||||
if (array_key_exists($key, $existing)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing[] = [
|
||||
'type' => $pair['type'],
|
||||
'identifier' => $pair['identifier'],
|
||||
'label' => $pair['label'],
|
||||
];
|
||||
}
|
||||
|
||||
$missing = array_values(collect($missing)->unique(fn (array $row) => $this->policyKey($row['type'], $row['identifier']))->all());
|
||||
|
||||
if ($missing === []) {
|
||||
return [
|
||||
'code' => 'missing_policies',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Target policies',
|
||||
'message' => 'All policies exist in the tenant (restore will update).',
|
||||
'meta' => [
|
||||
'missing_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'missing_policies',
|
||||
'severity' => 'warning',
|
||||
'title' => 'Target policies',
|
||||
'message' => sprintf('%d policies do not exist in the tenant and will be created.', count($missing)),
|
||||
'meta' => [
|
||||
'missing_count' => count($missing),
|
||||
'missing' => $this->truncateList($missing, 10),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkStalePolicies(Tenant $tenant, Collection $policyItems): ?array
|
||||
{
|
||||
$itemsByPolicyId = [];
|
||||
|
||||
foreach ($policyItems as $item) {
|
||||
if (! $item->policy_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$capturedAt = $item->captured_at;
|
||||
|
||||
if (! $capturedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemsByPolicyId[$item->policy_id][] = [
|
||||
'backup_item_id' => $item->id,
|
||||
'captured_at' => $capturedAt,
|
||||
'label' => $item->resolvedDisplayName(),
|
||||
];
|
||||
}
|
||||
|
||||
if ($itemsByPolicyId === []) {
|
||||
return [
|
||||
'code' => 'stale_policies',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Staleness',
|
||||
'message' => 'No captured timestamps available to evaluate staleness.',
|
||||
'meta' => [
|
||||
'stale_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$latestVersions = PolicyVersion::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('policy_id', array_keys($itemsByPolicyId))
|
||||
->selectRaw('policy_id, max(captured_at) as latest_captured_at')
|
||||
->groupBy('policy_id')
|
||||
->get()
|
||||
->mapWithKeys(function (PolicyVersion $version) {
|
||||
$latestCapturedAt = $version->getAttribute('latest_captured_at');
|
||||
|
||||
if (is_string($latestCapturedAt) && $latestCapturedAt !== '') {
|
||||
$latestCapturedAt = CarbonImmutable::parse($latestCapturedAt);
|
||||
} else {
|
||||
$latestCapturedAt = null;
|
||||
}
|
||||
|
||||
return [
|
||||
(int) $version->policy_id => $latestCapturedAt,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
|
||||
$stale = [];
|
||||
|
||||
foreach ($itemsByPolicyId as $policyId => $policyItems) {
|
||||
$latestCapturedAt = $latestVersions[(int) $policyId] ?? null;
|
||||
|
||||
if (! $latestCapturedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($policyItems as $policyItem) {
|
||||
if ($latestCapturedAt->greaterThan($policyItem['captured_at'])) {
|
||||
$stale[] = [
|
||||
'backup_item_id' => $policyItem['backup_item_id'],
|
||||
'label' => $policyItem['label'],
|
||||
'snapshot_captured_at' => $policyItem['captured_at']->toIso8601String(),
|
||||
'latest_captured_at' => $latestCapturedAt->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($stale === []) {
|
||||
return [
|
||||
'code' => 'stale_policies',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Staleness',
|
||||
'message' => 'No newer versions detected since the snapshot.',
|
||||
'meta' => [
|
||||
'stale_count' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'stale_policies',
|
||||
'severity' => 'warning',
|
||||
'title' => 'Staleness',
|
||||
'message' => sprintf('%d policies have newer versions in the tenant than this snapshot.', count($stale)),
|
||||
'meta' => [
|
||||
'stale_count' => count($stale),
|
||||
'stale' => $this->truncateList($stale, 10),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $items
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{code: string, severity: string, title: string, message: string, meta: array<string, mixed>}|null
|
||||
*/
|
||||
private function checkMissingScopeTagsInScope(Collection $items, Collection $policyItems, bool $isSelectedScope): ?array
|
||||
{
|
||||
if (! $isSelectedScope) {
|
||||
return [
|
||||
'code' => 'scope_tags_in_scope',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Scope tags',
|
||||
'message' => 'Scope includes all items; foundations are available if present in the backup set.',
|
||||
'meta' => [
|
||||
'missing_scope_tags' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$selectedScopeTagCount = $items->where('policy_type', 'roleScopeTag')->count();
|
||||
|
||||
$scopeTagIds = [];
|
||||
|
||||
foreach ($policyItems as $item) {
|
||||
$ids = $item->scope_tag_ids;
|
||||
|
||||
if (! is_array($ids)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (! is_string($id) || $id === '' || $id === '0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scopeTagIds[] = $id;
|
||||
}
|
||||
}
|
||||
|
||||
$scopeTagIds = array_values(array_unique($scopeTagIds));
|
||||
|
||||
if ($scopeTagIds === [] || $selectedScopeTagCount > 0) {
|
||||
return [
|
||||
'code' => 'scope_tags_in_scope',
|
||||
'severity' => 'safe',
|
||||
'title' => 'Scope tags',
|
||||
'message' => 'Scope tags look OK for the selected items.',
|
||||
'meta' => [
|
||||
'missing_scope_tags' => false,
|
||||
'referenced_scope_tags' => count($scopeTagIds),
|
||||
'selected_scope_tag_items' => $selectedScopeTagCount,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'scope_tags_in_scope',
|
||||
'severity' => 'warning',
|
||||
'title' => 'Scope tags',
|
||||
'message' => 'Policies reference scope tags, but scope tags are not included in the selected restore scope.',
|
||||
'meta' => [
|
||||
'missing_scope_tags' => true,
|
||||
'referenced_scope_tags' => count($scopeTagIds),
|
||||
'selected_scope_tag_items' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, BackupItem> $policyItems
|
||||
* @return array{0: array<int, string>, 1: array<string, string>}
|
||||
*/
|
||||
private function extractGroupIds(Collection $policyItems): array
|
||||
{
|
||||
$groupIds = [];
|
||||
$sourceNames = [];
|
||||
|
||||
foreach ($policyItems 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;
|
||||
}
|
||||
|
||||
$groupIds[] = $groupId;
|
||||
|
||||
$displayName = $target['group_display_name'] ?? null;
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
$sourceNames[$groupId] = $displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique($groupIds));
|
||||
|
||||
return [$groupIds, $sourceNames];
|
||||
}
|
||||
|
||||
private function formatGroupLabel(?string $name, string $id): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if (is_string($name) && $name !== '') {
|
||||
$parts[] = $name;
|
||||
}
|
||||
|
||||
$parts[] = Str::limit($id, 24, '...');
|
||||
|
||||
return implode(' • ', $parts);
|
||||
}
|
||||
|
||||
private function policyKey(string $type, string $identifier): string
|
||||
{
|
||||
return $type.'|'.$identifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveTypeMeta(?string $type): array
|
||||
{
|
||||
if (! is_string($type) || $type === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$types = array_merge(
|
||||
config('tenantpilot.supported_policy_types', []),
|
||||
config('tenantpilot.foundation_types', [])
|
||||
);
|
||||
|
||||
foreach ($types as $typeConfig) {
|
||||
if (($typeConfig['type'] ?? null) === $type) {
|
||||
return is_array($typeConfig) ? $typeConfig : [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function resolveRestoreMode(?string $policyType): string
|
||||
{
|
||||
$meta = $this->resolveTypeMeta($policyType);
|
||||
|
||||
return (string) ($meta['restore'] ?? 'enabled');
|
||||
}
|
||||
|
||||
private function resolveTypeLabel(?string $policyType): string
|
||||
{
|
||||
$meta = $this->resolveTypeMeta($policyType);
|
||||
|
||||
return (string) ($meta['label'] ?? $policyType ?? 'Unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function truncateList(array $items, int $limit): array
|
||||
{
|
||||
if (count($items) <= $limit) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return array_slice($items, 0, $limit);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
@php
|
||||
$results = $getState() ?? [];
|
||||
$results = is_array($results) ? $results : [];
|
||||
|
||||
$summary = $summary ?? [];
|
||||
$summary = is_array($summary) ? $summary : [];
|
||||
|
||||
$blocking = (int) ($summary['blocking'] ?? 0);
|
||||
$warning = (int) ($summary['warning'] ?? 0);
|
||||
$safe = (int) ($summary['safe'] ?? 0);
|
||||
|
||||
$ranAt = $ranAt ?? null;
|
||||
$ranAtLabel = null;
|
||||
|
||||
if (is_string($ranAt) && $ranAt !== '') {
|
||||
try {
|
||||
$ranAtLabel = \Carbon\CarbonImmutable::parse($ranAt)->format('Y-m-d H:i');
|
||||
} catch (\Throwable) {
|
||||
$ranAtLabel = $ranAt;
|
||||
}
|
||||
}
|
||||
|
||||
$severityColor = static function (?string $severity): string {
|
||||
return match ($severity) {
|
||||
'blocking' => 'danger',
|
||||
'warning' => 'warning',
|
||||
default => 'success',
|
||||
};
|
||||
};
|
||||
|
||||
$limitedList = static function (array $items, int $limit = 5): array {
|
||||
if (count($items) <= $limit) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return array_slice($items, 0, $limit);
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Safety checks"
|
||||
:description="$ranAtLabel ? ('Last run: ' . $ranAtLabel) : 'Run checks to evaluate risk before previewing.'"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge :color="$blocking > 0 ? 'danger' : 'gray'">
|
||||
{{ $blocking }} blocking
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$warning > 0 ? 'warning' : 'gray'">
|
||||
{{ $warning }} warnings
|
||||
</x-filament::badge>
|
||||
<x-filament::badge :color="$safe > 0 ? 'success' : 'gray'">
|
||||
{{ $safe }} safe
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($results === [])
|
||||
<x-filament::section>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
No checks have been run yet.
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@else
|
||||
<div class="space-y-3">
|
||||
@foreach ($results as $result)
|
||||
@php
|
||||
$severity = is_array($result) ? ($result['severity'] ?? 'safe') : 'safe';
|
||||
$title = is_array($result) ? ($result['title'] ?? $result['code'] ?? 'Check') : 'Check';
|
||||
$message = is_array($result) ? ($result['message'] ?? null) : null;
|
||||
$meta = is_array($result) ? ($result['meta'] ?? []) : [];
|
||||
$meta = is_array($meta) ? $meta : [];
|
||||
|
||||
$unmappedGroups = $meta['unmapped'] ?? [];
|
||||
$unmappedGroups = is_array($unmappedGroups) ? $limitedList($unmappedGroups) : [];
|
||||
@endphp
|
||||
|
||||
<x-filament::section>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ $title }}
|
||||
</div>
|
||||
@if (is_string($message) && $message !== '')
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$severityColor($severity)" size="sm">
|
||||
{{ ucfirst((string) $severity) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if ($unmappedGroups !== [])
|
||||
<div class="mt-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Unmapped groups
|
||||
</div>
|
||||
<ul class="mt-2 space-y-1 text-sm text-gray-700 dark:text-gray-200">
|
||||
@foreach ($unmappedGroups as $group)
|
||||
@php
|
||||
$label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null;
|
||||
@endphp
|
||||
@if (is_string($label) && $label !== '')
|
||||
<li>{{ $label }}</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament::section>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@ -22,8 +22,8 @@ ## Phase 3 — Restore Scope UX
|
||||
- [x] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates).
|
||||
|
||||
## Phase 4 — Safety & Conflict Checks
|
||||
- [ ] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`.
|
||||
- [ ] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
|
||||
- [x] T011 Implement `RestoreRiskChecker` (server-side) and persist `check_summary` + `check_results`.
|
||||
- [x] T012 Render check results with severity (blocking/warning/safe) and block execute when blockers exist.
|
||||
|
||||
## Phase 5 — Preview (Diff)
|
||||
- [ ] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`.
|
||||
|
||||
130
tests/Feature/RestoreRiskChecksWizardTest.php
Normal file
130
tests/Feature/RestoreRiskChecksWizardTest.php
Normal file
@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
putenv('INTUNE_TENANT_ID');
|
||||
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||
});
|
||||
|
||||
test('restore wizard can run safety checks and persists results on the restore run', function () {
|
||||
$tenant = Tenant::create([
|
||||
'tenant_id' => 'tenant-1',
|
||||
'name' => 'Tenant One',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'external_id' => 'policy-1',
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'display_name' => 'Settings Catalog',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Backup',
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => now(),
|
||||
'payload' => ['id' => $policy->external_id],
|
||||
'assignments' => [[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'source-group-1',
|
||||
'group_display_name' => 'Source Group',
|
||||
],
|
||||
'intent' => 'apply',
|
||||
]],
|
||||
]);
|
||||
|
||||
$this->mock(GroupResolver::class, function (MockInterface $mock) {
|
||||
$mock->shouldReceive('resolveGroupIds')
|
||||
->andReturnUsing(function (array $groupIds): array {
|
||||
return collect($groupIds)
|
||||
->mapWithKeys(fn (string $id) => [$id => [
|
||||
'id' => $id,
|
||||
'displayName' => null,
|
||||
'orphaned' => true,
|
||||
]])
|
||||
->all();
|
||||
});
|
||||
});
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(CreateRestoreRun::class)
|
||||
->fillForm([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->fillForm([
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
])
|
||||
->goToNextWizardStep()
|
||||
->assertFormComponentActionVisible('check_results', 'run_restore_checks')
|
||||
->callFormComponentAction('check_results', 'run_restore_checks');
|
||||
|
||||
$summary = $component->get('data.check_summary');
|
||||
$results = $component->get('data.check_results');
|
||||
|
||||
expect($summary)->toBeArray();
|
||||
expect($summary['blocking'] ?? null)->toBe(1);
|
||||
expect($summary['has_blockers'] ?? null)->toBeTrue();
|
||||
|
||||
expect($results)->toBeArray();
|
||||
expect($results)->not->toBeEmpty();
|
||||
|
||||
$assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups');
|
||||
expect($assignmentCheck)->toBeArray();
|
||||
expect($assignmentCheck['severity'] ?? null)->toBe('blocking');
|
||||
|
||||
$unmappedGroups = $assignmentCheck['meta']['unmapped'] ?? [];
|
||||
expect($unmappedGroups)->toBeArray();
|
||||
expect($unmappedGroups[0]['id'] ?? null)->toBe('source-group-1');
|
||||
|
||||
$checksRanAt = $component->get('data.checks_ran_at');
|
||||
expect($checksRanAt)->toBeString();
|
||||
|
||||
$component
|
||||
->goToNextWizardStep()
|
||||
->goToNextWizardStep()
|
||||
->call('create')
|
||||
->assertHasNoFormErrors();
|
||||
|
||||
$run = RestoreRun::query()->latest('id')->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run->metadata)->toHaveKeys([
|
||||
'check_summary',
|
||||
'check_results',
|
||||
'checks_ran_at',
|
||||
]);
|
||||
expect($run->metadata['check_summary']['blocking'] ?? null)->toBe(1);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user