merge: agent session work

This commit is contained in:
Ahmed Darrazi 2025-12-30 02:35:20 +01:00
commit f647da8da9
6 changed files with 300 additions and 1 deletions

View File

@ -30,6 +30,11 @@ public function table(Table $table): Table
->sortable()
->searchable()
->getStateUsing(fn (BackupItem $record) => $record->resolvedDisplayName()),
Tables\Columns\TextColumn::make('policyVersion.version_number')
->label('Version')
->badge()
->default('—')
->getStateUsing(fn (BackupItem $record): ?int => $record->policyVersion?->version_number),
Tables\Columns\TextColumn::make('policy_type')
->label('Type')
->badge()

View File

@ -2,7 +2,13 @@
namespace App\Filament\Resources\PolicyResource\RelationManagers;
use App\Filament\Resources\RestoreRunResource;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Intune\RestoreService;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
@ -24,6 +30,55 @@ public function table(Table $table): Table
->filters([])
->headerActions([])
->actions([
Actions\Action::make('restore_to_intune')
->label('Restore to Intune')
->icon('heroicon-o-arrow-path-rounded-square')
->color('danger')
->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.')
->form([
Forms\Components\Toggle::make('is_dry_run')
->label('Preview only (dry-run)')
->default(true),
])
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current();
if ($record->tenant_id !== $tenant->id) {
Notification::make()
->title('Policy version belongs to a different tenant')
->danger()
->send();
return;
}
try {
$run = $restoreService->executeFromPolicyVersion(
tenant: $tenant,
version: $record,
dryRun: (bool) ($data['is_dry_run'] ?? true),
actorEmail: auth()->user()?->email,
actorName: auth()->user()?->name,
);
} catch (\Throwable $throwable) {
Notification::make()
->title('Restore run failed to start')
->body($throwable->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Restore run started')
->success()
->send();
return redirect(RestoreRunResource::getUrl('view', ['record' => $run]));
}),
Actions\ViewAction::make()
->url(fn ($record) => \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $record]))
->openUrlInNewTab(false),

View File

@ -548,7 +548,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
->orWhereDoesntHave('policy')
->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at'));
})
->with('policy:id,display_name')
->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at'])
->get()
->sortBy(function (BackupItem $item) {
$meta = static::typeMeta($item->policy_type);
@ -570,6 +570,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
$platform = $item->platform ?? $meta['platform'] ?? null;
$displayName = $item->resolvedDisplayName();
$identifier = $item->policy_identifier ?? null;
$versionNumber = $item->policyVersion?->version_number;
$options[$item->id] = $displayName;
@ -578,6 +579,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
$typeLabel,
$platform,
"restore: {$restore}",
$versionNumber ? "version: {$versionNumber}" : null,
$item->hasAssignments() ? "assignments: {$item->assignment_count}" : null,
$identifier ? 'id: '.Str::limit($identifier, 24, '...') : null,
]);

View File

@ -5,6 +5,7 @@
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\AssignmentRestoreService;
@ -97,6 +98,88 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt
*
* @param array<int>|null $selectedItemIds
*/
public function executeFromPolicyVersion(
Tenant $tenant,
PolicyVersion $version,
bool $dryRun = true,
?string $actorEmail = null,
?string $actorName = null,
array $groupMapping = [],
): RestoreRun {
if ($version->tenant_id !== $tenant->id) {
throw new \InvalidArgumentException('Policy version does not belong to the provided tenant.');
}
$policy = $version->policy;
if (! $policy) {
throw new \RuntimeException('Policy version has no associated policy.');
}
$backupSet = BackupSet::create([
'tenant_id' => $tenant->id,
'name' => sprintf(
'Policy Version Restore • %s • v%d',
$policy->display_name,
$version->version_number
),
'created_by' => $actorEmail,
'status' => 'completed',
'item_count' => 1,
'completed_at' => CarbonImmutable::now(),
'metadata' => [
'source' => 'policy_version',
'policy_version_id' => $version->id,
'policy_version_number' => $version->version_number,
'policy_id' => $policy->id,
],
]);
$scopeTags = is_array($version->scope_tags) ? $version->scope_tags : [];
$scopeTagIds = $scopeTags['ids'] ?? null;
$scopeTagNames = $scopeTags['names'] ?? null;
$backupItemMetadata = [
'source' => 'policy_version',
'display_name' => $policy->display_name,
'policy_version_id' => $version->id,
'policy_version_number' => $version->version_number,
'version_captured_at' => $version->captured_at?->toIso8601String(),
];
if (is_array($scopeTagIds) && $scopeTagIds !== []) {
$backupItemMetadata['scope_tag_ids'] = $scopeTagIds;
}
if (is_array($scopeTagNames) && $scopeTagNames !== []) {
$backupItemMetadata['scope_tag_names'] = $scopeTagNames;
}
$backupItem = BackupItem::create([
'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id,
'policy_version_id' => $version->id,
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => $version->captured_at ?? CarbonImmutable::now(),
'payload' => $version->snapshot ?? [],
'metadata' => $backupItemMetadata,
'assignments' => $version->assignments,
]);
return $this->execute(
tenant: $tenant,
backupSet: $backupSet,
selectedItemIds: [$backupItem->id],
dryRun: $dryRun,
actorEmail: $actorEmail,
actorName: $actorName,
groupMapping: $groupMapping,
);
}
public function execute(
Tenant $tenant,
BackupSet $backupSet,

View File

@ -14,6 +14,7 @@ ## Phase 3: UI Normalization
- [x] T004 Add `GroupPolicyConfigurationNormalizer` and register it (Policy “Normalized settings” is readable).
- [x] T009 Make `Normalized diff` labels readable for `groupPolicyConfiguration` and `settingsCatalogPolicy`.
- [x] T010 Ensure restore-created versions keep assignments + show scope tags independently.
- [x] T012 Add “Restore to Intune” from a specific PolicyVersion (rollback) + show policy version numbers in restore selection UI.
## Phase 4: Tests + Verification
- [x] T005 Add tests for hydration + UI display.

View File

@ -0,0 +1,153 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphResponse;
use App\Services\Intune\RestoreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
class PolicyVersionRestoreGraphClient implements GraphClientInterface
{
/**
* @var array<int, array{method:string,path:string,payload:array|null}>
*/
public array $requestCalls = [];
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
{
return new GraphResponse(true, []);
}
public function request(string $method, string $path, array $options = []): GraphResponse
{
$method = strtoupper($method);
$this->requestCalls[] = [
'method' => $method,
'path' => $path,
'payload' => $options['json'] ?? null,
];
if ($method === 'GET') {
return new GraphResponse(true, ['value' => []]);
}
return new GraphResponse(true, []);
}
public function getServicePrincipalPermissions(array $options = []): GraphResponse
{
return new GraphResponse(true, []);
}
}
test('restore can execute from a specific policy version snapshot', function () {
$client = new PolicyVersionRestoreGraphClient;
app()->instance(GraphClientInterface::class, $client);
$tenant = Tenant::create([
'tenant_id' => 'tenant-version-restore',
'name' => 'Tenant One',
'metadata' => [],
]);
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'gpo-versioned-1',
'policy_type' => 'groupPolicyConfiguration',
'display_name' => 'Admin Templates',
'platform' => 'windows',
]);
$snapshotWithThree = [
'id' => $policy->external_id,
'displayName' => $policy->display_name,
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
'definitionValues' => collect(range(1, 3))
->map(fn (int $i) => [
'enabled' => true,
'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')",
'#Definition_Id' => "def-{$i}",
'#Definition_displayName' => "Setting {$i}",
])->all(),
];
$snapshotWithFive = [
'id' => $policy->external_id,
'displayName' => $policy->display_name,
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
'definitionValues' => collect(range(1, 5))
->map(fn (int $i) => [
'enabled' => true,
'definition@odata.bind' => "https://graph.microsoft.com/beta/deviceManagement/groupPolicyDefinitions('def-{$i}')",
'#Definition_Id' => "def-{$i}",
'#Definition_displayName' => "Setting {$i}",
])->all(),
];
PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => now(),
'snapshot' => $snapshotWithThree,
'metadata' => ['source' => 'version_capture'],
]);
$versionToRestore = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 2,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => 'tester@example.com',
'captured_at' => now(),
'snapshot' => $snapshotWithFive,
'metadata' => ['source' => 'version_capture'],
]);
$user = User::factory()->create(['email' => 'tester@example.com']);
$this->actingAs($user);
$service = app(RestoreService::class);
$run = $service->executeFromPolicyVersion(
tenant: $tenant,
version: $versionToRestore,
dryRun: false,
actorEmail: $user->email,
actorName: $user->name,
)->refresh();
expect($run->status)->toBe('completed');
expect($run->results[0]['status'])->toBe('applied');
expect($run->results[0]['definition_value_summary']['success'])->toBe(5);
$definitionValueCreateCalls = collect($client->requestCalls)
->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/definitionValues'))
->values();
expect($definitionValueCreateCalls)->toHaveCount(5);
});