feat: enforce preview-only restores
This commit is contained in:
parent
7e962e6faf
commit
a7f9580081
@ -52,6 +52,8 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
->where('policy_type', $item->policy_type)
|
->where('policy_type', $item->policy_type)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
$restoreMode = $this->resolveRestoreMode($item->policy_type);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'backup_item_id' => $item->id,
|
'backup_item_id' => $item->id,
|
||||||
'policy_identifier' => $item->policy_identifier,
|
'policy_identifier' => $item->policy_identifier,
|
||||||
@ -59,6 +61,7 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
|
|||||||
'platform' => $item->platform,
|
'platform' => $item->platform,
|
||||||
'action' => $existing ? 'update' : 'create',
|
'action' => $existing ? 'update' : 'create',
|
||||||
'conflict' => false,
|
'conflict' => false,
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
'validation_warning' => BackupItem::odataTypeWarning(
|
'validation_warning' => BackupItem::odataTypeWarning(
|
||||||
is_array($item->payload) ? $item->payload : [],
|
is_array($item->payload) ? $item->payload : [],
|
||||||
$item->policy_type,
|
$item->policy_type,
|
||||||
@ -151,6 +154,18 @@ public function execute(
|
|||||||
'backup_item_id' => $item->id,
|
'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(
|
$odataValidation = BackupItem::validateODataType(
|
||||||
is_array($item->payload) ? $item->payload : [],
|
is_array($item->payload) ? $item->payload : [],
|
||||||
$item->policy_type,
|
$item->policy_type,
|
||||||
@ -173,7 +188,10 @@ public function execute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$results[] = $context + ['status' => 'dry_run'];
|
$results[] = $context + [
|
||||||
|
'status' => 'dry_run',
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -342,7 +360,10 @@ public function execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $context + ['status' => $itemStatus];
|
$result = $context + [
|
||||||
|
'status' => $itemStatus,
|
||||||
|
'restore_mode' => $restoreMode,
|
||||||
|
];
|
||||||
|
|
||||||
if ($settingsApply !== null) {
|
if ($settingsApply !== null) {
|
||||||
$result['settings_apply'] = $settingsApply;
|
$result['settings_apply'] = $settingsApply;
|
||||||
@ -459,6 +480,37 @@ private function splitItems(Collection $items): array
|
|||||||
return [$foundationItems, $policyItems];
|
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
|
* @param array<int, array<string, mixed>> $entries
|
||||||
* @return array<string, array<string, string>>
|
* @return array<string, array<string, string>>
|
||||||
|
|||||||
@ -56,12 +56,22 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
|
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Policies</div>
|
||||||
@foreach ($policyItems as $item)
|
@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="rounded border border-gray-200 bg-white p-3 shadow-sm">
|
||||||
<div class="flex items-center justify-between text-sm text-gray-800">
|
<div class="flex items-center justify-between text-sm text-gray-800">
|
||||||
<span class="font-semibold">{{ $item['policy_identifier'] ?? 'Policy' }}</span>
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
{{ $item['action'] ?? 'action' }}
|
@if ($restoreMode === 'preview-only')
|
||||||
</span>
|
<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>
|
||||||
<div class="mt-1 text-xs text-gray-600">
|
<div class="mt-1 text-xs text-gray-600">
|
||||||
{{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }}
|
{{ $item['policy_type'] ?? 'type' }} • {{ $item['platform'] ?? 'platform' }}
|
||||||
|
|||||||
@ -78,23 +78,36 @@
|
|||||||
</div>
|
</div>
|
||||||
@php
|
@php
|
||||||
$status = $item['status'] ?? 'unknown';
|
$status = $item['status'] ?? 'unknown';
|
||||||
|
$restoreMode = $item['restore_mode'] ?? null;
|
||||||
$statusColor = match ($status) {
|
$statusColor = match ($status) {
|
||||||
'applied' => 'text-green-700 bg-green-100 border-green-200',
|
'applied' => 'text-green-700 bg-green-100 border-green-200',
|
||||||
'dry_run' => 'text-blue-700 bg-blue-100 border-blue-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',
|
'partial' => 'text-amber-900 bg-amber-50 border-amber-200',
|
||||||
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
'manual_required' => 'text-amber-900 bg-amber-100 border-amber-200',
|
||||||
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
'failed' => 'text-red-700 bg-red-100 border-red-200',
|
||||||
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
default => 'text-gray-700 bg-gray-100 border-gray-200',
|
||||||
};
|
};
|
||||||
@endphp
|
@endphp
|
||||||
<span class="rounded border px-2 py-0.5 text-xs font-semibold uppercase tracking-wide {{ $statusColor }}">
|
<div class="flex items-center gap-2">
|
||||||
{{ $status }}
|
@if ($restoreMode === 'preview-only')
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$itemReason = $item['reason'] ?? null;
|
$itemReason = $item['reason'] ?? null;
|
||||||
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
$itemGraphMessage = $item['graph_error_message'] ?? null;
|
||||||
|
|
||||||
|
if ($itemReason === 'preview_only') {
|
||||||
|
$itemReason = 'Preview-only policy type; execution skipped.';
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
@if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
|
||||||
|
|||||||
111
tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php
Normal file
111
tests/Feature/Filament/ConditionalAccessPreviewOnlyTest.php
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user