feat: enforce preview-only restores

This commit is contained in:
Ahmed Darrazi 2025-12-26 23:49:46 +01:00
parent 7e962e6faf
commit a7f9580081
4 changed files with 194 additions and 8 deletions

View File

@ -52,6 +52,8 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
->where('policy_type', $item->policy_type)
->first();
$restoreMode = $this->resolveRestoreMode($item->policy_type);
return [
'backup_item_id' => $item->id,
'policy_identifier' => $item->policy_identifier,
@ -59,6 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
'platform' => $item->platform,
'action' => $existing ? 'update' : 'create',
'conflict' => false,
'restore_mode' => $restoreMode,
'validation_warning' => BackupItem::odataTypeWarning(
is_array($item->payload) ? $item->payload : [],
$item->policy_type,
@ -151,6 +154,18 @@ public function execute(
'backup_item_id' => $item->id,
];
$restoreMode = $this->resolveRestoreMode($item->policy_type);
if ($restoreMode === 'preview-only') {
$results[] = $context + [
'status' => $dryRun ? 'dry_run' : 'skipped',
'reason' => 'preview_only',
'restore_mode' => $restoreMode,
];
continue;
}
$odataValidation = BackupItem::validateODataType(
is_array($item->payload) ? $item->payload : [],
$item->policy_type,
@ -173,7 +188,10 @@ public function execute(
}
if ($dryRun) {
$results[] = $context + ['status' => 'dry_run'];
$results[] = $context + [
'status' => 'dry_run',
'restore_mode' => $restoreMode,
];
continue;
}
@ -342,7 +360,10 @@ public function execute(
}
}
$result = $context + ['status' => $itemStatus];
$result = $context + [
'status' => $itemStatus,
'restore_mode' => $restoreMode,
];
if ($settingsApply !== null) {
$result['settings_apply'] = $settingsApply;
@ -459,6 +480,37 @@ private function splitItems(Collection $items): array
return [$foundationItems, $policyItems];
}
/**
* @return array<string, mixed>
*/
private function resolveTypeMeta(string $policyType): array
{
$types = array_merge(
config('tenantpilot.supported_policy_types', []),
config('tenantpilot.foundation_types', [])
);
foreach ($types as $typeConfig) {
if (($typeConfig['type'] ?? null) === $policyType) {
return $typeConfig;
}
}
return [];
}
private function resolveRestoreMode(string $policyType): string
{
$meta = $this->resolveTypeMeta($policyType);
$restore = $meta['restore'] ?? 'enabled';
if (! is_string($restore) || $restore === '') {
return 'enabled';
}
return $restore;
}
/**
* @param array<int, array<string, mixed>> $entries
* @return array<string, array<string, string>>

View File

@ -56,12 +56,22 @@
<div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
@foreach ($policyItems as $item)
@php
$restoreMode = $item['restore_mode'] ?? null;
@endphp
<div class="rounded border border-gray-200 bg-white p-3 shadow-sm">
<div class="flex items-center justify-between text-sm text-gray-800">
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
{{ $item['action'] ?? 'action' }}
</span>
<div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only')
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900">
preview-only
</span>
@endif
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs uppercase tracking-wide text-gray-700">
{{ $item['action'] ?? 'action' }}
</span>
</div>
</div>
<div class="mt-1 text-xs text-gray-600">
{{ $item['policy_type'] ?? 'type' }} {{ $item['platform'] ?? 'platform' }}

View File

@ -78,23 +78,36 @@
</div>
@php
$status = $item['status'] ?? 'unknown';
$restoreMode = $item['restore_mode'] ?? null;
$statusColor = match ($status) {
'applied' => 'text-green-700 bg-green-100 border-green-200',
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-200',
'skipped' => 'text-amber-900 bg-amber-50 border-amber-200',
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
'failed' => 'text-red-700 bg-red-100 border-red-200',
default => 'text-gray-700 bg-gray-100 border-gray-200',
};
@endphp
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
{{ $status }}
</span>
<div class="flex items-center gap-2">
@if ($restoreMode === 'preview-only')
<span class="rounded border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-amber-900">
preview-only
</span>
@endif
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
{{ $status }}
</span>
</div>
</div>
@php
$itemReason = $item['reason'] ?? null;
$itemGraphMessage = $item['graph_error_message'] ?? null;
if ($itemReason === 'preview_only') {
$itemReason = 'Preview-only policy type; execution skipped.';
}
@endphp
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))

View File

@ -0,0 +1,111 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('conditional access restores are preview-only and skipped on execution', function () {
$client = new class implements GraphClientInterface
{
public int $applyCalls = 0;
public function listPolicies(string $policyType, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
{
return new GraphResponse(true, ['payload' => []]);
}
public function getOrganization(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
{
$this->applyCalls++;
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
};
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-ca',
'name' => 'Tenant CA',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'ca-policy-1',
'policy_type' => 'conditionalAccessPolicy',
'display_name' => 'CA Policy',
'platform' => 'all',
]);
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => 'CA 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,
'payload' => [
'@odata.type' => '#microsoft.graph.conditionalAccessPolicy',
'id' => $policy->external_id,
'displayName' => $policy->display_name,
],
]);
$service = app(RestoreService::class);
$preview = $service->preview($tenant, $backupSet, [$backupItem->id]);
$previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'conditionalAccessPolicy');
expect($previewItem)->not->toBeNull()
->and($previewItem['restore_mode'] ?? null)->toBe('preview-only');
$run = $service->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: false,
actorEmail: 'tester@example.com',
actorName: 'Tester',
);
expect($run->results)->toHaveCount(1);
expect($run->results[0]['status'])->toBe('skipped');
expect($run->results[0]['reason'])->toBe('preview_only');
expect($client->applyCalls)->toBe(0);
});