From b048131f818c72b1787a82021f39aec1768bae97 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 31 Dec 2025 19:14:59 +0000 Subject: [PATCH 01/18] feat/011-restore-run-wizard (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wichtige Änderungen: - Eine neue "Restore via Wizard"-Aktion wurde der PolicyVersion-Tabelle hinzugefügt. - Diese Aktion ermöglicht die Erstellung eines Einzelposten-BackupSets aus dem ausgewählten Policy-Version-Snapshot. - Der CreateRestoreRun Wizard unterstützt nun das Vorbefüllen seiner Formularfelder basierend auf Abfrageparametern, was eine nahtlose Übergabe von der PolicyVersion-Aktion ermöglicht. - Umfassende Feature-Tests wurden hinzugefügt, um die korrekte Funktionalität und Integration dieses neuen Workflows sicherzustellen. - Die specs/011-restore-run-wizard/tasks.md wurde aktualisiert, um den Abschluss von Aufgabe T023 widerzuspiegeln. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/17 --- .../Resources/PolicyVersionResource.php | 93 ++ app/Filament/Resources/RestoreRunResource.php | 875 +++++++++++++++++- .../Pages/CreateRestoreRun.php | 105 +++ app/Jobs/ExecuteRestoreRunJob.php | 134 +++ app/Models/RestoreRun.php | 25 +- app/Services/Intune/RestoreDiffGenerator.php | 248 +++++ app/Services/Intune/RestoreRiskChecker.php | 608 ++++++++++++ app/Services/Intune/RestoreService.php | 110 ++- app/Support/RestoreRunStatus.php | 73 ++ .../components/restore-run-checks.blade.php | 121 +++ .../components/restore-run-preview.blade.php | 180 ++++ specs/011-restore-run-wizard/tasks.md | 44 +- tests/Feature/ExecuteRestoreRunJobTest.php | 68 ++ .../PolicyVersionRestoreViaWizardTest.php | 164 ++++ .../Filament/RestoreItemSelectionTest.php | 66 +- tests/Feature/RestoreGroupMappingTest.php | 26 +- .../Feature/RestorePreviewDiffWizardTest.php | 132 +++ tests/Feature/RestoreRiskChecksWizardTest.php | 222 +++++ tests/Feature/RestoreRunWizardExecuteTest.php | 165 ++++ .../Feature/RestoreRunWizardMetadataTest.php | 86 ++ 20 files changed, 3454 insertions(+), 91 deletions(-) create mode 100644 app/Jobs/ExecuteRestoreRunJob.php create mode 100644 app/Services/Intune/RestoreDiffGenerator.php create mode 100644 app/Services/Intune/RestoreRiskChecker.php create mode 100644 app/Support/RestoreRunStatus.php create mode 100644 resources/views/filament/forms/components/restore-run-checks.blade.php create mode 100644 resources/views/filament/forms/components/restore-run-preview.blade.php create mode 100644 tests/Feature/ExecuteRestoreRunJobTest.php create mode 100644 tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php create mode 100644 tests/Feature/RestorePreviewDiffWizardTest.php create mode 100644 tests/Feature/RestoreRiskChecksWizardTest.php create mode 100644 tests/Feature/RestoreRunWizardExecuteTest.php create mode 100644 tests/Feature/RestoreRunWizardMetadataTest.php diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index bb3e19d..4bab649 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -6,6 +6,8 @@ use App\Jobs\BulkPolicyVersionForceDeleteJob; use App\Jobs\BulkPolicyVersionPruneJob; use App\Jobs\BulkPolicyVersionRestoreJob; +use App\Models\BackupItem; +use App\Models\BackupSet; use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\BulkOperationService; @@ -13,6 +15,7 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\VersionDiff; use BackedEnum; +use Carbon\CarbonImmutable; use Filament\Actions; use Filament\Actions\BulkAction; use Filament\Actions\BulkActionGroup; @@ -183,6 +186,96 @@ public static function table(Table $table): Table ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) ->openUrlInNewTab(false), Actions\ActionGroup::make([ + Actions\Action::make('restore_via_wizard') + ->label('Restore via Wizard') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('primary') + ->requiresConfirmation() + ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") + ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') + ->action(function (PolicyVersion $record) { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant || $record->tenant_id !== $tenant->id) { + Notification::make() + ->title('Policy version belongs to a different tenant') + ->danger() + ->send(); + + return; + } + + $policy = $record->policy; + + if (! $policy) { + Notification::make() + ->title('Policy could not be found for this version') + ->danger() + ->send(); + + return; + } + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => sprintf( + 'Policy Version Restore • %s • v%d', + $policy->display_name, + $record->version_number + ), + 'created_by' => $user?->email, + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => CarbonImmutable::now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'policy_id' => $policy->id, + ], + ]); + + $scopeTags = is_array($record->scope_tags) ? $record->scope_tags : []; + $scopeTagIds = $scopeTags['ids'] ?? null; + $scopeTagNames = $scopeTags['names'] ?? null; + + $backupItemMetadata = [ + 'source' => 'policy_version', + 'display_name' => $policy->display_name, + 'policy_version_id' => $record->id, + 'policy_version_number' => $record->version_number, + 'version_captured_at' => $record->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' => $record->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => $record->captured_at ?? CarbonImmutable::now(), + 'payload' => $record->snapshot ?? [], + 'metadata' => $backupItemMetadata, + 'assignments' => $record->assignments, + ]); + + return redirect()->to(RestoreRunResource::getUrl('create', [ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])); + }), Actions\Action::make('archive') ->label('Archive') ->color('danger') diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 3f7088d..ff2500b 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -6,6 +6,7 @@ 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\RestoreRun; @@ -13,7 +14,11 @@ use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GroupResolver; +use App\Services\Intune\AuditLogger; +use App\Services\Intune\RestoreDiffGenerator; +use App\Services\Intune\RestoreRiskChecker; use App\Services\Intune\RestoreService; +use App\Support\RestoreRunStatus; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -26,13 +31,16 @@ 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\Tables; use Filament\Tables\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; use UnitEnum; class RestoreRunResource extends Resource @@ -69,8 +77,10 @@ public static function form(Schema $schema): Schema }) ->reactive() ->afterStateUpdated(function (Set $set): void { - $set('backup_item_ids', []); + $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') @@ -137,6 +147,491 @@ public static function form(Schema $schema): Schema ]); } + /** + * @return array + */ + public static function getWizardSteps(): array + { + return [ + Step::make('Select Backup Set') + ->description('What are we restoring from?') + ->schema([ + Forms\Components\Select::make('backup_set_id') + ->label('Backup set') + ->options(function () { + $tenantId = Tenant::current()->getKey(); + + return BackupSet::query() + ->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId)) + ->orderByDesc('created_at') + ->get() + ->mapWithKeys(function (BackupSet $set) { + $label = sprintf( + '%s • %s items • %s', + $set->name, + $set->item_count ?? 0, + optional($set->created_at)->format('Y-m-d H:i') + ); + + return [$set->id => $label]; + }); + }) + ->reactive() + ->afterStateUpdated(function (Set $set, Get $get): void { + $set('scope_mode', 'all'); + $set('backup_item_ids', null); + $set('group_mapping', static::groupMappingPlaceholders( + backupSetId: $get('backup_set_id'), + scopeMode: 'all', + selectedItemIds: null, + tenant: Tenant::current(), + )); + $set('is_dry_run', true); + $set('acknowledged_impact', false); + $set('tenant_confirm', null); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); + $set('preview_summary', null); + $set('preview_diffs', []); + $set('preview_ran_at', null); + }) + ->required(), + ]), + Step::make('Define Restore Scope') + ->description('What exactly should be restored?') + ->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 = Tenant::current(); + $set('is_dry_run', true); + $set('acknowledged_impact', false); + $set('tenant_confirm', null); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); + $set('preview_summary', null); + $set('preview_diffs', []); + $set('preview_ran_at', null); + + if ($state === 'all') { + $set('backup_item_ids', null); + $set('group_mapping', static::groupMappingPlaceholders( + backupSetId: $backupSetId, + scopeMode: 'all', + selectedItemIds: null, + tenant: $tenant, + )); + + return; + } + + $set('group_mapping', []); + $set('backup_item_ids', []); + }) + ->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 = Tenant::current(); + + $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); + $set('check_summary', null); + $set('check_results', []); + $set('checks_ran_at', null); + $set('preview_summary', null); + $set('preview_diffs', []); + $set('preview_ran_at', null); + }) + ->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('Search by name or ID. Include foundations (scope tags, assignment filters) with policies to re-map IDs. Options are grouped by category, type, and platform.'), + Section::make('Group mapping') + ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') + ->schema(function (Get $get): array { + $backupSetId = $get('backup_set_id'); + $scopeMode = $get('scope_mode') ?? 'all'; + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + 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 + ); + + return array_map(function (array $group) use ($tenant): Forms\Components\Select { + $groupId = $group['id']; + $label = $group['label']; + + return Forms\Components\Select::make("group_mapping.{$groupId}") + ->label($label) + ->options([ + 'SKIP' => 'Skip assignment', + ]) + ->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); + $set('preview_summary', null); + $set('preview_diffs', []); + $set('preview_ran_at', null); + }) + ->helperText('Choose a target group or select Skip.'); + }, $unresolved); + }) + ->visible(function (Get $get): bool { + $backupSetId = $get('backup_set_id'); + $scopeMode = $get('scope_mode') ?? 'all'; + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + 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 + ) !== []; + }), + ]), + Step::make('Safety & Conflict Checks') + ->description('Is this dangerous?') + ->schema([ + 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 = static::normalizeGroupMapping($get('group_mapping')); + + $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); + + if ($blockers > 0) { + $set('is_dry_run', true, shouldCallUpdatedHooks: true); + } + + 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('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); + }), + ]) + ->helperText('Run checks after defining scope and mapping missing groups.'), + ]), + Step::make('Preview') + ->description('Dry-run preview') + ->schema([ + Forms\Components\Hidden::make('preview_summary') + ->default(null), + Forms\Components\Hidden::make('preview_ran_at') + ->default(null) + ->required(), + 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'), + ]) + ->hintActions([ + Actions\Action::make('run_restore_preview') + ->label('Generate preview') + ->icon('heroicon-o-eye') + ->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 generate preview') + ->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; + + $generator = app(RestoreDiffGenerator::class); + $outcome = $generator->generate( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + ); + + $summary = $outcome['summary'] ?? []; + $diffs = $outcome['diffs'] ?? []; + + $set('preview_summary', $summary, shouldCallUpdatedHooks: true); + $set('preview_diffs', $diffs, shouldCallUpdatedHooks: true); + $set('preview_ran_at', $summary['generated_at'] ?? now()->toIso8601String(), shouldCallUpdatedHooks: true); + + $policiesChanged = (int) ($summary['policies_changed'] ?? 0); + $policiesTotal = (int) ($summary['policies_total'] ?? 0); + + Notification::make() + ->title('Preview generated') + ->body("Policies: {$policiesChanged}/{$policiesTotal} changed") + ->status($policiesChanged > 0 ? 'warning' : 'success') + ->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); + }), + ]) + ->helperText('Generate a normalized diff preview before creating the dry-run restore.'), + ]), + Step::make('Confirm & Execute') + ->description('Point of no return') + ->schema([ + Forms\Components\Placeholder::make('confirm_environment') + ->label('Environment') + ->content(fn (): string => app()->environment('production') ? 'prod' : 'test'), + Forms\Components\Placeholder::make('confirm_tenant_label') + ->label('Tenant hard-confirm label') + ->content(function (): string { + $tenant = Tenant::current(); + + if (! $tenant) { + return ''; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + return (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + }), + Forms\Components\Toggle::make('is_dry_run') + ->label('Preview only (dry-run)') + ->default(true) + ->reactive() + ->disabled(function (Get $get): bool { + if (! filled($get('checks_ran_at'))) { + return true; + } + + $summary = $get('check_summary'); + + if (! is_array($summary)) { + return false; + } + + return (int) ($summary['blocking'] ?? 0) > 0; + }) + ->helperText('Turn OFF to queue a real execution. Execution requires checks + preview + confirmations.'), + 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 tenant 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 = Tenant::current(); + + if (! $tenant) { + return []; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + return [(string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey())]; + }) + ->validationMessages([ + 'in' => 'Tenant hard-confirm does not match.', + ]) + ->helperText(function (): string { + $tenant = Tenant::current(); + + if (! $tenant) { + return ''; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + $expected = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + return "Type: {$expected}"; + }), + ]), + ]; + } + public static function table(Table $table): Table { return $table @@ -533,11 +1028,72 @@ private static function restoreItemOptionData(?int $backupSetId): array ]; } - static $cache = []; - $cacheKey = $tenant->getKey().':'.$backupSetId; + $cacheKey = sprintf('restore_run_item_options:%s:%s', $tenant->getKey(), $backupSetId); - if (isset($cache[$cacheKey])) { - return $cache[$cacheKey]; + 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('tenant_id', $tenant->getKey())) + ->where(function ($query) { + $query->whereNull('policy_id') + ->orWhereDoesntHave('policy') + ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); + }) + ->with(['policy:id,display_name', '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); + $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; + + $options[$item->id] = $displayName; + + $parts = array_filter([ + $category, + $typeLabel, + $platform, + "restore: {$restore}", + $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> + */ + private static function restoreItemGroupedOptions(?int $backupSetId): array + { + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return []; } $items = BackupItem::query() @@ -548,49 +1104,40 @@ private static function restoreItemOptionData(?int $backupSetId): array ->orWhereDoesntHave('policy') ->orWhereHas('policy', fn ($policyQuery) => $policyQuery->whereNull('ignored_at')); }) - ->with(['policy:id,display_name', 'policyVersion:id,version_number,captured_at']) + ->with(['policy:id,display_name']) ->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.'-'.$name); + return strtolower($categoryKey.'-'.$typeLabel.'-'.$platform.'-'.$name); }); - $options = []; - $descriptions = []; + $groups = []; foreach ($items as $item) { $meta = static::typeMeta($item->policy_type); $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; + $platform = $item->platform ?? $meta['platform'] ?? 'all'; + $restoreMode = $meta['restore'] ?? 'enabled'; - $options[$item->id] = $displayName; - - $parts = array_filter([ + $groupLabel = implode(' • ', array_filter([ $category, $typeLabel, $platform, - "restore: {$restore}", - $versionNumber ? "version: {$versionNumber}" : null, - $item->hasAssignments() ? "assignments: {$item->assignment_count}" : null, - $identifier ? 'id: '.Str::limit($identifier, 24, '...') : null, - ]); + $restoreMode === 'preview-only' ? 'preview-only' : null, + ])); - $descriptions[$item->id] = implode(' • ', $parts); + $groups[$groupLabel] ??= []; + $groups[$groupLabel][$item->id] = $item->resolvedDisplayName(); } - return $cache[$cacheKey] = [ - 'options' => $options, - 'descriptions' => $descriptions, - ]; + return $groups; } public static function createRestoreRun(array $data): RestoreRun @@ -608,15 +1155,170 @@ public static function createRestoreRun(array $data): RestoreRun /** @var RestoreService $service */ $service = app(RestoreService::class); - return $service->execute( + $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); + + $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->tenant_id ?? $tenant->external_id; + $highlanderLabel = (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()); + + if (! $isDryRun) { + if (! is_array($checkSummary) || ! filled($checksRanAt)) { + throw ValidationException::withMessages([ + 'check_summary' => 'Run safety checks before executing.', + ]); + } + + $blocking = (int) ($checkSummary['blocking'] ?? 0); + $hasBlockers = (bool) ($checkSummary['has_blockers'] ?? ($blocking > 0)); + + if ($blocking > 0 || $hasBlockers) { + throw ValidationException::withMessages([ + 'check_summary' => 'Blocking checks must be resolved before executing.', + ]); + } + + if (! filled($previewRanAt)) { + throw ValidationException::withMessages([ + 'preview_ran_at' => 'Generate preview 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' => 'Tenant 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; + } + + $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, + ]; + + 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; + } + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + + app(AuditLogger::class)->log( tenant: $tenant, - backupSet: $backupSet, - selectedItemIds: $data['backup_item_ids'] ?? null, - dryRun: (bool) ($data['is_dry_run'] ?? true), - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - groupMapping: $data['group_mapping'] ?? [], + action: 'restore.queued', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', ); + + ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName); + + return $restoreRun->refresh(); } /** @@ -703,6 +1405,111 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return $unresolved; } + /** + * @param array|null $selectedItemIds + * @return array + */ + private static function groupMappingPlaceholders(?int $backupSetId, string $scopeMode, ?array $selectedItemIds, ?Tenant $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 + */ + 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 !== ''); + } + /** * @return array */ diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2c22eb1..2e4e6de 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -3,13 +3,118 @@ namespace App\Filament\Resources\RestoreRunResource\Pages; use App\Filament\Resources\RestoreRunResource; +use App\Models\BackupSet; +use App\Models\Tenant; +use Filament\Actions\Action; +use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; class CreateRestoreRun extends CreateRecord { + use HasWizard; + protected static string $resource = RestoreRunResource::class; + public function getSteps(): array + { + return RestoreRunResource::getWizardSteps(); + } + + protected function afterFill(): void + { + $backupSetIdRaw = request()->query('backup_set_id'); + + if (! is_numeric($backupSetIdRaw)) { + return; + } + + $backupSetId = (int) $backupSetIdRaw; + + if ($backupSetId <= 0) { + return; + } + + $tenant = Tenant::current(); + + if (! $tenant) { + return; + } + + $belongsToTenant = BackupSet::query() + ->where('tenant_id', $tenant->id) + ->whereKey($backupSetId) + ->exists(); + + if (! $belongsToTenant) { + return; + } + + $backupItemIds = $this->normalizeBackupItemIds(request()->query('backup_item_ids')); + $scopeModeRaw = request()->query('scope_mode'); + $scopeMode = in_array($scopeModeRaw, ['all', 'selected'], true) + ? $scopeModeRaw + : ($backupItemIds !== [] ? 'selected' : 'all'); + + $this->data['backup_set_id'] = $backupSetId; + $this->form->callAfterStateUpdated('data.backup_set_id'); + + $this->data['scope_mode'] = $scopeMode; + $this->form->callAfterStateUpdated('data.scope_mode'); + + if ($scopeMode === 'selected') { + if ($backupItemIds !== []) { + $this->data['backup_item_ids'] = $backupItemIds; + } + + $this->form->callAfterStateUpdated('data.backup_item_ids'); + } + } + + /** + * @return array + */ + private function normalizeBackupItemIds(mixed $raw): array + { + if (is_string($raw)) { + $raw = array_filter(array_map('trim', explode(',', $raw))); + } + + if (! is_array($raw)) { + return []; + } + + $itemIds = []; + + foreach ($raw as $value) { + if (is_int($value) && $value > 0) { + $itemIds[] = $value; + + continue; + } + + if (is_string($value) && ctype_digit($value)) { + $itemId = (int) $value; + + if ($itemId > 0) { + $itemIds[] = $itemId; + } + } + } + + $itemIds = array_values(array_unique($itemIds)); + sort($itemIds); + + return $itemIds; + } + + protected function getSubmitFormAction(): Action + { + return parent::getSubmitFormAction() + ->label('Create restore run') + ->icon('heroicon-o-check-circle'); + } + protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); diff --git a/app/Jobs/ExecuteRestoreRunJob.php b/app/Jobs/ExecuteRestoreRunJob.php new file mode 100644 index 0000000..dd57d20 --- /dev/null +++ b/app/Jobs/ExecuteRestoreRunJob.php @@ -0,0 +1,134 @@ +find($this->restoreRunId); + + if (! $restoreRun) { + return; + } + + if ($restoreRun->status !== RestoreRunStatus::Queued->value) { + return; + } + + $tenant = $restoreRun->tenant; + $backupSet = $restoreRun->backupSet; + + if (! $tenant || ! $backupSet || $backupSet->trashed()) { + $restoreRun->update([ + 'status' => RestoreRunStatus::Failed->value, + 'failure_reason' => 'Backup set is archived or unavailable.', + 'completed_at' => CarbonImmutable::now(), + ]); + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'restore.failed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $restoreRun->backup_set_id, + 'reason' => 'Backup set is archived or unavailable.', + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'failed', + ); + } + + return; + } + + $restoreRun->update([ + 'status' => RestoreRunStatus::Running->value, + 'started_at' => CarbonImmutable::now(), + 'failure_reason' => null, + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'restore.started', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success', + ); + + try { + $restoreService->executeForRun( + restoreRun: $restoreRun, + tenant: $tenant, + backupSet: $backupSet, + actorEmail: $this->actorEmail, + actorName: $this->actorName, + ); + } catch (Throwable $throwable) { + $restoreRun->refresh(); + + if ($restoreRun->status === RestoreRunStatus::Running->value) { + $restoreRun->update([ + 'status' => RestoreRunStatus::Failed->value, + 'failure_reason' => $throwable->getMessage(), + 'completed_at' => CarbonImmutable::now(), + ]); + } + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'restore.failed', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'reason' => $throwable->getMessage(), + ], + ], + actorEmail: $this->actorEmail, + actorName: $this->actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'failed', + ); + } + + throw $throwable; + } + } +} diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 28945c4..e4d3ce0 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Support\RestoreRunStatus; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -35,17 +37,30 @@ public function backupSet(): BelongsTo return $this->belongsTo(BackupSet::class)->withTrashed(); } - public function scopeDeletable($query) + public function scopeDeletable(Builder $query): Builder { - return $query->whereIn('status', ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed']); + return $query->whereIn('status', array_map( + static fn (RestoreRunStatus $status): string => $status->value, + [ + RestoreRunStatus::Draft, + RestoreRunStatus::Scoped, + RestoreRunStatus::Checked, + RestoreRunStatus::Previewed, + RestoreRunStatus::Completed, + RestoreRunStatus::Partial, + RestoreRunStatus::Failed, + RestoreRunStatus::Cancelled, + RestoreRunStatus::Aborted, + RestoreRunStatus::CompletedWithErrors, + ] + )); } public function isDeletable(): bool { - $status = strtolower(trim((string) $this->status)); - $status = str_replace([' ', '-'], '_', $status); + $status = RestoreRunStatus::fromString($this->status); - return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); + return $status?->isDeletable() ?? false; } // Group mapping helpers diff --git a/app/Services/Intune/RestoreDiffGenerator.php b/app/Services/Intune/RestoreDiffGenerator.php new file mode 100644 index 0000000..200eb65 --- /dev/null +++ b/app/Services/Intune/RestoreDiffGenerator.php @@ -0,0 +1,248 @@ +|null $selectedItemIds + * @return array{summary: array, diffs: array>} + */ + public function generate(Tenant $tenant, BackupSet $backupSet, ?array $selectedItemIds = null): array + { + if ($backupSet->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Backup set does not belong to the provided tenant.'); + } + + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + + $items = $this->loadItems($backupSet, $selectedItemIds); + $policyItems = $items + ->reject(fn (BackupItem $item): bool => $item->isFoundation()) + ->values(); + + $policyIds = $policyItems + ->pluck('policy_id') + ->filter() + ->unique() + ->values() + ->all(); + + $latestVersions = $this->latestVersionsByPolicyId($tenant, $policyIds); + + $maxDetailedDiffs = 25; + $maxEntriesPerSection = 200; + + $policiesChanged = 0; + $assignmentsChanged = 0; + $scopeTagsChanged = 0; + + $diffs = []; + $diffsOmitted = 0; + + foreach ($policyItems as $index => $item) { + $policyId = $item->policy_id ? (int) $item->policy_id : null; + $currentVersion = $policyId ? ($latestVersions[$policyId] ?? null) : null; + + $currentSnapshot = is_array($currentVersion?->snapshot) ? $currentVersion->snapshot : []; + $backupSnapshot = is_array($item->payload) ? $item->payload : []; + + $policyType = (string) ($item->policy_type ?? ''); + $platform = $item->platform; + + $from = $this->policyNormalizer->flattenForDiff($currentSnapshot, $policyType, $platform); + $to = $this->policyNormalizer->flattenForDiff($backupSnapshot, $policyType, $platform); + + $diff = $this->versionDiff->compare($from, $to); + $summary = $diff['summary'] ?? ['added' => 0, 'removed' => 0, 'changed' => 0]; + + $hasPolicyChanges = ((int) ($summary['added'] ?? 0) + (int) ($summary['removed'] ?? 0) + (int) ($summary['changed'] ?? 0)) > 0; + + if ($hasPolicyChanges) { + $policiesChanged++; + } + + $assignmentDiff = $this->assignmentsChanged($item->assignments, $currentVersion?->assignments); + if ($assignmentDiff) { + $assignmentsChanged++; + } + + $scopeTagDiff = $this->scopeTagsChanged($item, $currentVersion); + if ($scopeTagDiff) { + $scopeTagsChanged++; + } + + $diffEntry = [ + 'backup_item_id' => $item->id, + 'display_name' => $item->resolvedDisplayName(), + 'policy_identifier' => $item->policy_identifier, + 'policy_type' => $policyType, + 'platform' => $platform, + 'action' => $currentVersion ? 'update' : 'create', + 'diff' => [ + 'summary' => $summary, + 'added' => [], + 'removed' => [], + 'changed' => [], + ], + 'assignments_changed' => $assignmentDiff, + 'scope_tags_changed' => $scopeTagDiff, + 'diff_omitted' => false, + 'diff_truncated' => false, + ]; + + if ($index >= $maxDetailedDiffs) { + $diffEntry['diff_omitted'] = true; + $diffEntry['diff_truncated'] = true; + $diffEntry['diff'] = [ + 'summary' => $summary, + ]; + $diffsOmitted++; + $diffs[] = $diffEntry; + + continue; + } + + $added = is_array($diff['added'] ?? null) ? $diff['added'] : []; + $removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : []; + $changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : []; + + $diffEntry['diff_truncated'] = count($added) > $maxEntriesPerSection + || count($removed) > $maxEntriesPerSection + || count($changed) > $maxEntriesPerSection; + + $diffEntry['diff'] = [ + 'summary' => $summary, + 'added' => array_slice($added, 0, $maxEntriesPerSection, true), + 'removed' => array_slice($removed, 0, $maxEntriesPerSection, true), + 'changed' => array_slice($changed, 0, $maxEntriesPerSection, true), + ]; + + $diffs[] = $diffEntry; + } + + return [ + 'summary' => [ + 'generated_at' => CarbonImmutable::now()->toIso8601String(), + 'policies_total' => $policyItems->count(), + 'policies_changed' => $policiesChanged, + 'assignments_changed' => $assignmentsChanged, + 'scope_tags_changed' => $scopeTagsChanged, + 'diffs_detailed' => min($policyItems->count(), $maxDetailedDiffs), + 'diffs_omitted' => $diffsOmitted, + 'limits' => [ + 'max_detailed_diffs' => $maxDetailedDiffs, + 'max_entries_per_section' => $maxEntriesPerSection, + ], + ], + 'diffs' => $diffs, + ]; + } + + /** + * @param array|null $selectedItemIds + * @return Collection + */ + 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 array $policyIds + * @return array + */ + private function latestVersionsByPolicyId(Tenant $tenant, array $policyIds): array + { + if ($policyIds === []) { + return []; + } + + $latestVersionsQuery = PolicyVersion::query() + ->where('tenant_id', $tenant->id) + ->whereIn('policy_id', $policyIds) + ->selectRaw('policy_id, max(version_number) as version_number') + ->groupBy('policy_id'); + + return PolicyVersion::query() + ->where('tenant_id', $tenant->id) + ->joinSub($latestVersionsQuery, 'latest_versions', function ($join): void { + $join->on('policy_versions.policy_id', '=', 'latest_versions.policy_id') + ->on('policy_versions.version_number', '=', 'latest_versions.version_number'); + }) + ->get() + ->keyBy('policy_id') + ->all(); + } + + private function assignmentsChanged(?array $backupAssignments, ?array $currentAssignments): bool + { + $backup = $this->normalizeAssignments($backupAssignments); + $current = $this->normalizeAssignments($currentAssignments); + + return $backup !== $current; + } + + private function scopeTagsChanged(BackupItem $backupItem, ?PolicyVersion $currentVersion): bool + { + $backupIds = $backupItem->scope_tag_ids; + $backupIds = is_array($backupIds) ? $backupIds : []; + $backupIds = array_values(array_filter($backupIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0')); + sort($backupIds); + + $scopeTags = $currentVersion?->scope_tags; + $currentIds = is_array($scopeTags) ? ($scopeTags['ids'] ?? []) : []; + $currentIds = is_array($currentIds) ? $currentIds : []; + $currentIds = array_values(array_filter($currentIds, fn (mixed $id): bool => is_string($id) && $id !== '' && $id !== '0')); + sort($currentIds); + + return $backupIds !== $currentIds; + } + + /** + * @return array> + */ + private function normalizeAssignments(?array $assignments): array + { + $assignments = is_array($assignments) ? $assignments : []; + + $normalized = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $normalized[] = $assignment; + } + + usort($normalized, function (array $a, array $b): int { + $left = json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; + $right = json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?: ''; + + return $left <=> $right; + }); + + return $normalized; + } +} diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php new file mode 100644 index 0000000..96ff30c --- /dev/null +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -0,0 +1,608 @@ +|null $selectedItemIds + * @param array $groupMapping + * @return array{summary: array{blocking: int, warning: int, safe: int, has_blockers: bool}, results: array}>} + */ + 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|null $selectedItemIds + * @return Collection + */ + 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 $policyItems + * @param array $groupMapping + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $items + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|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 $policyItems + * @return array{0: array, 1: array} + */ + 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 + */ + 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> $items + * @return array> + */ + private function truncateList(array $items, int $limit): array + { + if (count($items) <= $limit) { + return $items; + } + + return array_slice($items, 0, $limit); + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index d5082b1..e9e02d1 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -40,6 +40,10 @@ public function preview(Tenant $tenant, BackupSet $backupSet, ?array $selectedIt { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); @@ -180,6 +184,45 @@ public function executeFromPolicyVersion( ); } + public function executeForRun( + RestoreRun $restoreRun, + Tenant $tenant, + BackupSet $backupSet, + ?string $actorEmail = null, + ?string $actorName = null, + ): RestoreRun { + $this->assertActiveContext($tenant, $backupSet); + + if ($restoreRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($restoreRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + if (in_array($restoreRun->status, ['completed', 'partial', 'failed', 'cancelled'], true)) { + throw new \RuntimeException('Restore run is already finished.'); + } + + $selectedItemIds = is_array($restoreRun->requested_items) ? $restoreRun->requested_items : null; + + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + + return $this->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: $selectedItemIds, + dryRun: (bool) $restoreRun->is_dry_run, + actorEmail: $actorEmail, + actorName: $actorName, + groupMapping: $restoreRun->group_mapping ?? [], + existingRun: $restoreRun, + ); + } + public function execute( Tenant $tenant, BackupSet $backupSet, @@ -188,26 +231,65 @@ public function execute( ?string $actorEmail = null, ?string $actorName = null, array $groupMapping = [], + ?RestoreRun $existingRun = null, ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); + if ($selectedItemIds === []) { + $selectedItemIds = null; + } + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; $items = $this->loadItems($backupSet, $selectedItemIds); [$foundationItems, $policyItems] = $this->splitItems($items); $preview = $this->preview($tenant, $backupSet, $selectedItemIds); - $restoreRun = RestoreRun::create([ - 'tenant_id' => $tenant->id, - 'backup_set_id' => $backupSet->id, - 'requested_by' => $actorEmail, - 'is_dry_run' => $dryRun, - 'status' => 'running', - 'requested_items' => $selectedItemIds, - 'preview' => $preview, - 'started_at' => CarbonImmutable::now(), - 'metadata' => [], - 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, - ]); + $wizardMetadata = [ + 'scope_mode' => $selectedItemIds === null ? 'all' : 'selected', + 'environment' => app()->environment('production') ? 'prod' : 'test', + 'highlander_label' => (string) ($tenant->name ?? $tenantIdentifier ?? $tenant->getKey()), + ]; + + if ($existingRun !== null) { + if ($existingRun->tenant_id !== $tenant->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided tenant.'); + } + + if ($existingRun->backup_set_id !== $backupSet->id) { + throw new \InvalidArgumentException('Restore run does not belong to the provided backup set.'); + } + + $metadata = array_merge($wizardMetadata, $existingRun->metadata ?? []); + + $existingRun->update([ + 'requested_by' => $existingRun->requested_by ?? $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'results' => null, + 'failure_reason' => null, + 'started_at' => $existingRun->started_at ?? CarbonImmutable::now(), + 'completed_at' => null, + 'metadata' => $metadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : ($existingRun->group_mapping ?? null), + ]); + + $restoreRun = $existingRun->refresh(); + } else { + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => $actorEmail, + 'is_dry_run' => $dryRun, + 'status' => 'running', + 'requested_items' => $selectedItemIds, + 'preview' => $preview, + 'started_at' => CarbonImmutable::now(), + 'metadata' => $wizardMetadata, + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, + ]); + } if ($groupMapping !== []) { $this->auditLogger->log( @@ -740,12 +822,12 @@ public function execute( 'status' => $status, 'results' => $results, 'completed_at' => CarbonImmutable::now(), - 'metadata' => [ + 'metadata' => array_merge($restoreRun->metadata ?? [], [ 'failed' => $hardFailures, 'non_applied' => $nonApplied, 'total' => $totalCount, 'foundations_skipped' => $foundationSkipped, - ], + ]), ]); $this->auditLogger->log( diff --git a/app/Support/RestoreRunStatus.php b/app/Support/RestoreRunStatus.php new file mode 100644 index 0000000..727c4e5 --- /dev/null +++ b/app/Support/RestoreRunStatus.php @@ -0,0 +1,73 @@ + in_array($next, [self::Scoped, self::Cancelled], true), + self::Scoped => in_array($next, [self::Checked, self::Cancelled], true), + self::Checked => in_array($next, [self::Previewed, self::Cancelled], true), + self::Previewed => in_array($next, [self::Queued, self::Cancelled], true), + self::Pending => in_array($next, [self::Queued, self::Running, self::Cancelled], true), + self::Queued => in_array($next, [self::Running, self::Cancelled], true), + self::Running => in_array($next, [self::Completed, self::Partial, self::Failed, self::Cancelled], true), + self::Completed, + self::Partial, + self::Failed, + self::Cancelled, + self::Aborted, + self::CompletedWithErrors => false, + }; + } + + public function isDeletable(): bool + { + return in_array($this, [ + self::Draft, + self::Scoped, + self::Checked, + self::Previewed, + self::Completed, + self::Partial, + self::Failed, + self::Cancelled, + self::Aborted, + self::CompletedWithErrors, + ], true); + } +} diff --git a/resources/views/filament/forms/components/restore-run-checks.blade.php b/resources/views/filament/forms/components/restore-run-checks.blade.php new file mode 100644 index 0000000..e7469ba --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-checks.blade.php @@ -0,0 +1,121 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + + $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 + + +
+ +
+ + {{ $blocking }} blocking + + + {{ $warning }} warnings + + + {{ $safe }} safe + +
+
+ + @if ($results === []) + +
+ No checks have been run yet. +
+
+ @else +
+ @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 + + +
+
+
+ {{ $title }} +
+ @if (is_string($message) && $message !== '') +
+ {{ $message }} +
+ @endif +
+ + + {{ ucfirst((string) $severity) }} + +
+ + @if ($unmappedGroups !== []) +
+
+ Unmapped groups +
+
    + @foreach ($unmappedGroups as $group) + @php + $label = is_array($group) ? ($group['label'] ?? $group['id'] ?? null) : null; + @endphp + @if (is_string($label) && $label !== '') +
  • {{ $label }}
  • + @endif + @endforeach +
+
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/filament/forms/components/restore-run-preview.blade.php b/resources/views/filament/forms/components/restore-run-preview.blade.php new file mode 100644 index 0000000..f92e4b1 --- /dev/null +++ b/resources/views/filament/forms/components/restore-run-preview.blade.php @@ -0,0 +1,180 @@ +@php + $fieldWrapperView = $getFieldWrapperView(); + + $diffs = $getState() ?? []; + $diffs = is_array($diffs) ? $diffs : []; + + $summary = $summary ?? []; + $summary = is_array($summary) ? $summary : []; + + $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; + } + } + + $policiesTotal = (int) ($summary['policies_total'] ?? 0); + $policiesChanged = (int) ($summary['policies_changed'] ?? 0); + $assignmentsChanged = (int) ($summary['assignments_changed'] ?? 0); + $scopeTagsChanged = (int) ($summary['scope_tags_changed'] ?? 0); + $diffsOmitted = (int) ($summary['diffs_omitted'] ?? 0); + + $limitedKeys = static function (array $items, int $limit = 8): array { + $keys = array_keys($items); + + if (count($keys) <= $limit) { + return $keys; + } + + return array_slice($keys, 0, $limit); + }; +@endphp + + +
+ +
+ + {{ $policiesChanged }}/{{ $policiesTotal }} policies changed + + + {{ $assignmentsChanged }} assignments changed + + + {{ $scopeTagsChanged }} scope tags changed + + @if ($diffsOmitted > 0) + + {{ $diffsOmitted }} diffs omitted (limit) + + @endif +
+
+ + @if ($diffs === []) + +
+ No preview generated yet. +
+
+ @else +
+ @foreach ($diffs as $entry) + @php + $entry = is_array($entry) ? $entry : []; + $name = $entry['display_name'] ?? $entry['policy_identifier'] ?? 'Item'; + $type = $entry['policy_type'] ?? 'type'; + $platform = $entry['platform'] ?? 'platform'; + $action = $entry['action'] ?? 'update'; + $diff = is_array($entry['diff'] ?? null) ? $entry['diff'] : []; + $diffSummary = is_array($diff['summary'] ?? null) ? $diff['summary'] : []; + + $added = (int) ($diffSummary['added'] ?? 0); + $removed = (int) ($diffSummary['removed'] ?? 0); + $changed = (int) ($diffSummary['changed'] ?? 0); + + $assignmentsDelta = (bool) ($entry['assignments_changed'] ?? false); + $scopeTagsDelta = (bool) ($entry['scope_tags_changed'] ?? false); + $diffOmitted = (bool) ($entry['diff_omitted'] ?? false); + $diffTruncated = (bool) ($entry['diff_truncated'] ?? false); + + $changedKeys = $limitedKeys(is_array($diff['changed'] ?? null) ? $diff['changed'] : []); + $addedKeys = $limitedKeys(is_array($diff['added'] ?? null) ? $diff['added'] : []); + $removedKeys = $limitedKeys(is_array($diff['removed'] ?? null) ? $diff['removed'] : []); + @endphp + + +
+ + {{ $action }} + + + {{ $added }} added + + + {{ $removed }} removed + + + {{ $changed }} changed + + @if ($assignmentsDelta) + + assignments + + @endif + @if ($scopeTagsDelta) + + scope tags + + @endif + @if ($diffTruncated) + + truncated + + @endif +
+ + @if ($diffOmitted) +
+ Diff details omitted due to preview limits. Narrow scope to see more items in detail. +
+ @elseif ($changedKeys !== [] || $addedKeys !== [] || $removedKeys !== []) +
+ @if ($changedKeys !== []) +
+
+ Changed keys (sample) +
+
    + @foreach ($changedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($addedKeys !== []) +
+
+ Added keys (sample) +
+
    + @foreach ($addedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif + @if ($removedKeys !== []) +
+
+ Removed keys (sample) +
+
    + @foreach ($removedKeys as $key) +
  • + {{ $key }} +
  • + @endforeach +
+
+ @endif +
+ @endif +
+ @endforeach +
+ @endif +
+
diff --git a/specs/011-restore-run-wizard/tasks.md b/specs/011-restore-run-wizard/tasks.md index 323e85b..e6c7b48 100644 --- a/specs/011-restore-run-wizard/tasks.md +++ b/specs/011-restore-run-wizard/tasks.md @@ -7,37 +7,39 @@ ## Phase 0 — Specs (this PR) - [x] T001 Create `spec.md`, `plan.md`, `tasks.md` for Feature 011. ## Phase 1 — Data Model + Status Semantics -- [ ] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). -- [ ] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). -- [ ] T004 Freeze `environment` + `highlander_label` at run creation for audit. +- [x] T002 Define RestoreRun lifecycle statuses and transitions (draft→scoped→checked→previewed→queued→running→completed|partial|failed). +- [x] T003 Add minimal persistence for wizard state (prefer JSON in `restore_runs.metadata` unless columns are required). +- [x] T004 Freeze `environment` + `highlander_label` at run creation for audit. ## Phase 2 — Filament Wizard (Create Restore Run) -- [ ] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). -- [ ] T006 Ensure changing `backup_set_id` resets downstream wizard state. -- [ ] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. +- [x] T005 Replace current single-form create with a 5-step wizard (Step 1–5 as in spec). +- [x] T006 Ensure changing `backup_set_id` resets downstream wizard state. +- [x] T007 Enforce “dry-run default ON” and keep execute disabled until all gates satisfied. ## Phase 3 — Restore Scope UX -- [ ] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. -- [ ] T009 Mark preview-only types clearly and ensure they never execute. -- [ ] T010 Ensure foundations are discoverable (assignment filters, scope tags, notification templates). +- [x] T008 Implement scoped selection UI grouped by policy type + platform with search and bulk toggle. +- [x] T009 Mark preview-only types clearly and ensure they never execute. +- [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`. -- [ ] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. +- [x] T013 Implement `RestoreDiffGenerator` using `PolicyNormalizer` + `VersionDiff`. +- [x] T014 Persist preview summary (and per-item diffs with safe limits) and require preview completion before execute. ## Phase 6 — Confirm & Execute -- [ ] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). -- [ ] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. -- [ ] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. +- [x] T015 Implement Step 5 confirmations (ack checkbox + tenant hard-confirm). +- [x] T016 Execute restore via a queued Job (preferred) and update statuses + timestamps. +- [x] T017 Persist execution outcomes and ensure audit logging entries exist for execution start/finish. ## Phase 7 — Tests + Formatting -- [ ] T018 Add Pest tests for wizard gating rules and status transitions. -- [ ] T019 Add Pest tests for safety checks persistence and blocking behavior. -- [ ] T020 Add Pest tests for preview summary generation. -- [ ] T021 Run `./vendor/bin/pint --dirty`. -- [ ] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). +- [x] T018 Add Pest tests for wizard gating rules and status transitions. +- [x] T019 Add Pest tests for safety checks persistence and blocking behavior. +- [x] T020 Add Pest tests for preview summary generation. +- [x] T021 Run `./vendor/bin/pint --dirty`. +- [x] T022 Run targeted tests (e.g. `./vendor/bin/sail artisan test --filter=RestoreRunWizard` once tests exist). +## Phase 8 — Policy Version Entry Point (later) +- [x] T023 Add a “Restore via Wizard” action on `PolicyVersion` that creates a 1-item Backup Set (source = policy_version) and opens the Restore Run wizard prefilled/scoped to that item. \ No newline at end of file diff --git a/tests/Feature/ExecuteRestoreRunJobTest.php b/tests/Feature/ExecuteRestoreRunJobTest.php new file mode 100644 index 0000000..9fb258a --- /dev/null +++ b/tests/Feature/ExecuteRestoreRunJobTest.php @@ -0,0 +1,68 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $restoreRun = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'requested_by' => 'actor@example.com', + 'is_dry_run' => false, + 'status' => RestoreRunStatus::Queued->value, + 'requested_items' => null, + 'preview' => [], + 'results' => null, + 'metadata' => [], + ]); + + $restoreService = $this->mock(RestoreService::class, function (MockInterface $mock) use ($tenant, $backupSet) { + $mock->shouldReceive('executeForRun') + ->once() + ->withArgs(function (RestoreRun $run, Tenant $runTenant, BackupSet $runBackupSet, ?string $email, ?string $name) use ($tenant, $backupSet): bool { + return $run->status === RestoreRunStatus::Running->value + && $runTenant->is($tenant) + && $runBackupSet->is($backupSet) + && $email === 'actor@example.com' + && $name === 'Actor'; + }) + ->andReturnUsing(function (RestoreRun $run): RestoreRun { + $run->update([ + 'status' => RestoreRunStatus::Completed->value, + 'completed_at' => now(), + ]); + + return $run->refresh(); + }); + }); + + $job = new ExecuteRestoreRunJob($restoreRun->id, 'actor@example.com', 'Actor'); + $job->handle($restoreService, app(AuditLogger::class)); + + $restoreRun->refresh(); + + expect($restoreRun->started_at)->not->toBeNull(); + expect($restoreRun->status)->toBe(RestoreRunStatus::Completed->value); +}); diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php new file mode 100644 index 0000000..7a84965 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -0,0 +1,164 @@ + 'tenant-policy-version-wizard', + 'name' => 'Tenant', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog', + 'platform' => 'windows', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 3, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name], + 'assignments' => [['intent' => 'apply']], + 'scope_tags' => [ + 'ids' => ['st-1'], + 'names' => ['Tag 1'], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + Livewire::test(ListPolicyVersions::class) + ->callTableAction('restore_via_wizard', $version) + ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false)); + + $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); + expect($backupSet)->not->toBeNull(); + expect($backupSet->tenant_id)->toBe($tenant->id); + expect($backupSet->metadata['policy_version_id'] ?? null)->toBe($version->id); + + $backupItem = BackupItem::query()->where('backup_set_id', $backupSet->id)->first(); + expect($backupItem)->not->toBeNull(); + expect($backupItem->policy_version_id)->toBe($version->id); + expect($backupItem->policy_identifier)->toBe($policy->external_id); + expect($backupItem->metadata['scope_tag_ids'] ?? null)->toBe(['st-1']); + expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']); +}); + +test('restore run wizard can be prefilled from query params for policy version backup set', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-policy-version-prefill', + 'name' => 'Tenant', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog', + 'platform' => 'windows', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + 'group_display_name' => 'Source Group', + ], + 'intent' => 'apply', + ]], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Policy Version Restore', + 'status' => 'completed', + 'item_count' => 1, + 'completed_at' => now(), + 'metadata' => [ + 'source' => 'policy_version', + 'policy_version_id' => $version->id, + ], + ]); + + $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, + 'payload' => $version->snapshot ?? [], + 'assignments' => $version->assignments, + ]); + + $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::withQueryParams([ + 'backup_set_id' => $backupSet->id, + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ])->test(CreateRestoreRun::class); + + expect($component->get('data.backup_set_id'))->toBe($backupSet->id); + expect($component->get('data.scope_mode'))->toBe('selected'); + expect($component->get('data.backup_item_ids'))->toBe([$backupItem->id]); + + $mapping = $component->get('data.group_mapping'); + expect($mapping)->toBeArray(); + expect(array_key_exists('source-group-1', $mapping))->toBeTrue(); + expect($mapping['source-group-1'])->toBeNull(); + + $component + ->goToNextWizardStep() + ->assertFormFieldVisible('group_mapping.source-group-1'); +}); diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index a3ce170..7ac2911 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -1,5 +1,6 @@ create(['status' => 'active']); $tenant->makeCurrent(); @@ -22,6 +23,13 @@ 'display_name' => 'Policy Display', 'platform' => 'windows', ]); + $previewOnlyPolicy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-preview-only', + 'policy_type' => 'conditionalAccessPolicy', + 'display_name' => 'Conditional Access Policy', + 'platform' => 'all', + ]); $ignoredPolicy = Policy::factory()->create([ 'tenant_id' => $tenant->id, 'external_id' => 'policy-ignored', @@ -32,10 +40,10 @@ ]); $backupSet = BackupSet::factory()->for($tenant)->create([ - 'item_count' => 2, + 'item_count' => 4, ]); - BackupItem::factory() + $policyItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -47,7 +55,7 @@ ]) ->create(); - BackupItem::factory() + $ignoredPolicyItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -59,7 +67,7 @@ ]) ->create(); - BackupItem::factory() + $scopeTagItem = BackupItem::factory() ->for($tenant) ->for($backupSet) ->state([ @@ -77,6 +85,18 @@ ]) ->create(); + $previewOnlyItem = BackupItem::factory() + ->for($tenant) + ->for($backupSet) + ->state([ + 'policy_id' => $previewOnlyPolicy->id, + 'policy_identifier' => $previewOnlyPolicy->external_id, + 'policy_type' => $previewOnlyPolicy->policy_type, + 'platform' => $previewOnlyPolicy->platform, + 'payload' => ['id' => $previewOnlyPolicy->external_id], + ]) + ->create(); + $user = User::factory()->create(); $this->actingAs($user); @@ -84,13 +104,33 @@ ->fillForm([ 'backup_set_id' => $backupSet->id, ]) - ->assertSee('Policy Display') - ->assertDontSee('Ignored Policy') - ->assertSee('Scope Tag Alpha') - ->assertSee('Settings Catalog Policy') - ->assertSee('Scope Tag') - ->assertSee('restore: enabled') - ->assertSee('id: policy-1') - ->assertSee('id: tag-1') + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + ]) + ->assertFormFieldVisible('backup_item_ids') ->assertSee('Include foundations'); + + $method = new ReflectionMethod(RestoreRunResource::class, 'restoreItemGroupedOptions'); + $method->setAccessible(true); + + $groupedOptions = $method->invoke(null, $backupSet->id); + + expect($groupedOptions)->toHaveKey('Configuration • Settings Catalog Policy • windows'); + expect($groupedOptions)->toHaveKey('Foundations • Scope Tag • all'); + expect($groupedOptions)->toHaveKey('Conditional Access • Conditional Access • all • preview-only'); + + $flattenedOptions = collect($groupedOptions) + ->reduce(fn (array $carry, array $options): array => $carry + $options, []); + + expect($flattenedOptions)->toHaveKey($policyItem->id); + expect($flattenedOptions[$policyItem->id])->toBe('Policy Display'); + + expect($flattenedOptions)->not->toHaveKey($ignoredPolicyItem->id); + + expect($flattenedOptions)->toHaveKey($scopeTagItem->id); + expect($flattenedOptions[$scopeTagItem->id])->toBe('Scope Tag Alpha'); + + expect($flattenedOptions)->toHaveKey($previewOnlyItem->id); + expect($flattenedOptions[$previewOnlyItem->id])->toBe('Conditional Access Policy'); }); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 2397796..5746b5b 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -77,12 +77,23 @@ $user = User::factory()->create(); $this->actingAs($user); - Livewire::test(CreateRestoreRun::class) + $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, - 'backup_item_ids' => [$backupItem->id], ]) - ->assertFormFieldVisible('group_mapping.source-group-1'); + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]); + + $mapping = $component->get('data.group_mapping'); + + expect($mapping)->toBeArray(); + expect(array_key_exists('source-group-1', $mapping))->toBeTrue(); + expect($mapping['source-group-1'])->toBeNull(); + + $component->assertFormFieldVisible('group_mapping.source-group-1'); }); test('restore wizard persists group mapping selections', function () { @@ -150,12 +161,19 @@ Livewire::test(CreateRestoreRun::class) ->fillForm([ 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', 'backup_item_ids' => [$backupItem->id], 'group_mapping' => [ 'source-group-1' => 'target-group-1', ], - 'is_dry_run' => true, ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() ->call('create') ->assertHasNoFormErrors(); diff --git a/tests/Feature/RestorePreviewDiffWizardTest.php b/tests/Feature/RestorePreviewDiffWizardTest.php new file mode 100644 index 0000000..ab62af1 --- /dev/null +++ b/tests/Feature/RestorePreviewDiffWizardTest.php @@ -0,0 +1,132 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now()->subDay(), + 'snapshot' => [ + 'foo' => 'current', + ], + 'metadata' => [], + 'assignments' => [], + 'scope_tags' => [ + 'ids' => ['tag-2'], + 'names' => ['Tag Two'], + ], + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + 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' => [ + 'foo' => 'backup', + ], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-1', + ], + 'intent' => 'apply', + ]], + 'metadata' => [ + 'scope_tag_ids' => ['tag-1'], + 'scope_tag_names' => ['Tag One'], + ], + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $component = Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview'); + + $summary = $component->get('data.preview_summary'); + $diffs = $component->get('data.preview_diffs'); + + expect($summary)->toBeArray(); + expect($summary['policies_total'] ?? null)->toBe(1); + expect($summary['policies_changed'] ?? null)->toBe(1); + expect($summary['assignments_changed'] ?? null)->toBe(1); + expect($summary['scope_tags_changed'] ?? null)->toBe(1); + + expect($diffs)->toBeArray(); + expect($diffs)->not->toBeEmpty(); + + $first = $diffs[0] ?? []; + expect($first)->toBeArray(); + expect($first['action'] ?? null)->toBe('update'); + expect($first['assignments_changed'] ?? null)->toBeTrue(); + expect($first['scope_tags_changed'] ?? null)->toBeTrue(); + expect($first['diff']['summary']['changed'] ?? null)->toBe(1); + + $component + ->goToNextWizardStep() + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->metadata)->toHaveKeys([ + 'preview_summary', + 'preview_diffs', + 'preview_ran_at', + ]); + expect($run->metadata['preview_summary']['policies_changed'] ?? null)->toBe(1); +}); diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php new file mode 100644 index 0000000..c878d84 --- /dev/null +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -0,0 +1,222 @@ + '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() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->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); +}); + +test('restore wizard treats skipped orphaned groups as a warning instead of a blocker', 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() + ->set('data.group_mapping', (object) [ + 'source-group-1' => 'SKIP', + ]) + ->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(0); + expect($summary['has_blockers'] ?? null)->toBeFalse(); + expect($summary['warning'] ?? null)->toBe(1); + + $assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups'); + expect($assignmentCheck)->toBeArray(); + expect($assignmentCheck['severity'] ?? null)->toBe('warning'); + + $skippedGroups = $assignmentCheck['meta']['skipped'] ?? []; + expect($skippedGroups)->toBeArray(); + expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1'); +}); diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php new file mode 100644 index 0000000..4332917 --- /dev/null +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -0,0 +1,165 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + '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, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'tester@example.com', + 'name' => 'Tester', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + ]) + ->call('create') + ->assertHasFormErrors(['acknowledged_impact', 'tenant_confirm']); + + expect(RestoreRun::count())->toBe(0); +}); + +test('restore run wizard queues execution when gates are satisfied', function () { + Bus::fake(); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-2', + 'name' => 'Tenant Two', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-2', + 'policy_type' => 'deviceConfiguration', + 'display_name' => 'Device Config Policy', + '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, + 'payload' => ['id' => $policy->external_id], + 'metadata' => [ + 'displayName' => 'Backup Policy', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'executor@example.com', + 'name' => 'Executor', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->callFormComponentAction('check_results', 'run_restore_checks') + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->fillForm([ + 'is_dry_run' => false, + 'acknowledged_impact' => true, + 'tenant_confirm' => 'Tenant Two', + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->status)->toBe(RestoreRunStatus::Queued->value); + expect($run->is_dry_run)->toBeFalse(); + expect($run->metadata['confirmed_by'] ?? null)->toBe('executor@example.com'); + expect($run->metadata['confirmed_at'] ?? null)->toBeString(); + + Bus::assertDispatched(ExecuteRestoreRunJob::class); +}); diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php new file mode 100644 index 0000000..10c9697 --- /dev/null +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -0,0 +1,86 @@ + 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $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' => null, + 'policy_identifier' => 'policy-1', + 'policy_type' => 'deviceConfiguration', + 'platform' => 'windows', + 'payload' => ['id' => 'policy-1'], + 'metadata' => [ + 'displayName' => 'Backup Policy One', + ], + ]); + + $user = User::factory()->create([ + 'email' => 'tester@example.com', + 'name' => 'Tester', + ]); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->goToNextWizardStep() + ->callFormComponentAction('preview_diffs', 'run_restore_preview') + ->goToNextWizardStep() + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::query()->latest('id')->first(); + + expect($run)->not->toBeNull(); + expect($run->metadata)->toHaveKeys([ + 'scope_mode', + 'environment', + 'highlander_label', + 'failed', + 'non_applied', + 'total', + 'foundations_skipped', + ]); + + expect($run->metadata['scope_mode'])->toBe('selected'); + expect($run->metadata['environment'])->toBe('test'); + expect($run->metadata['highlander_label'])->toBe('Tenant One'); +}); From 286d3c596bac884ad3e2b32de746fd3b496a63db Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 1 Jan 2026 10:44:17 +0000 Subject: [PATCH 02/18] feat/012-windows-update-rings (#18) Created a safe session branch, committed everything, fast-forward merged back into feat/012-windows-update-rings, then pushed. Commit: 074a656 feat(rings): update rings + update profiles Push is done; upstream tracking is se Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/18 --- app/Models/Tenant.php | 8 +- app/Providers/AppServiceProvider.php | 6 + app/Services/Intune/PolicySnapshotService.php | 61 +++++ app/Services/Intune/RestoreService.php | 17 ++ .../WindowsFeatureUpdateProfileNormalizer.php | 107 +++++++++ .../WindowsQualityUpdateProfileNormalizer.php | 83 +++++++ .../Intune/WindowsUpdateRingNormalizer.php | 137 +++++++++++ .../Concerns/InteractsWithODataTypes.php | 8 + config/graph_contracts.php | 53 +++++ config/tenantpilot.php | 26 ++- phpunit.xml | 2 + specs/012-windows-update-rings/plan.md | 18 ++ specs/012-windows-update-rings/spec.md | 77 +++++++ specs/012-windows-update-rings/tasks.md | 26 +++ .../Feature/BulkProgressNotificationTest.php | 4 +- ...AppProtectionPolicySettingsDisplayTest.php | 2 +- tests/Feature/Filament/BackupCreationTest.php | 2 +- tests/Feature/Filament/HousekeepingTest.php | 12 + .../Filament/MalformedSnapshotWarningTest.php | 2 +- .../Filament/ODataTypeMismatchTest.php | 2 +- tests/Feature/Filament/PolicyListingTest.php | 2 +- .../Filament/PolicySettingsDisplayTest.php | 3 +- .../PolicyVersionReadableLayoutTest.php | 3 +- .../PolicyVersionScopeTagsDisplayTest.php | 3 +- .../Filament/PolicyVersionSettingsTest.php | 3 +- tests/Feature/Filament/PolicyVersionTest.php | 4 +- .../PolicyViewSettingsCatalogReadableTest.php | 20 +- ...ingsCatalogPolicyNormalizedDisplayTest.php | 3 +- .../SettingsCatalogPolicySyncTest.php | 7 +- ...SettingsCatalogSettingsTableRenderTest.php | 3 +- .../WindowsUpdateProfilesRestoreTest.php | 213 ++++++++++++++++++ .../Filament/WindowsUpdateRingPolicyTest.php | 77 +++++++ .../Filament/WindowsUpdateRingRestoreTest.php | 151 +++++++++++++ .../AppProtectionPolicySyncFilteringTest.php | 2 +- .../Jobs/PolicySyncIgnoredRevivalTest.php | 4 +- tests/Feature/PolicySyncServiceTest.php | 77 +++++++ tests/TestCase.php | 11 +- tests/Unit/FoundationSnapshotServiceTest.php | 2 +- tests/Unit/PolicySnapshotServiceTest.php | 82 +++++++ tests/Unit/TenantCurrentTest.php | 24 ++ 40 files changed, 1298 insertions(+), 49 deletions(-) create mode 100644 app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php create mode 100644 app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php create mode 100644 app/Services/Intune/WindowsUpdateRingNormalizer.php create mode 100644 specs/012-windows-update-rings/plan.md create mode 100644 specs/012-windows-update-rings/spec.md create mode 100644 specs/012-windows-update-rings/tasks.md create mode 100644 tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php create mode 100644 tests/Feature/Filament/WindowsUpdateRingPolicyTest.php create mode 100644 tests/Feature/Filament/WindowsUpdateRingRestoreTest.php create mode 100644 tests/Feature/PolicySyncServiceTest.php diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index bc0a134..3944280 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -104,13 +104,17 @@ public function makeCurrent(): void DB::transaction(function () { static::activeQuery()->update(['is_current' => false]); - $this->forceFill(['is_current' => true])->save(); + static::query() + ->whereKey($this->getKey()) + ->update(['is_current' => true]); }); + + $this->forceFill(['is_current' => true]); } public static function current(): self { - $envTenantId = env('INTUNE_TENANT_ID') ?: null; + $envTenantId = getenv('INTUNE_TENANT_ID') ?: null; if ($envTenantId) { $tenant = static::activeQuery() diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 517a762..dbf8384 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,9 @@ use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; +use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; +use App\Services\Intune\WindowsUpdateRingNormalizer; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -40,6 +43,9 @@ public function register(): void DeviceConfigurationPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, SettingsCatalogPolicyNormalizer::class, + WindowsFeatureUpdateProfileNormalizer::class, + WindowsQualityUpdateProfileNormalizer::class, + WindowsUpdateRingNormalizer::class, ], 'policy-type-normalizers' ); diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index c173b1b..3e6b82a 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -77,6 +77,16 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null $metadata = Arr::except($response->data, ['payload']); $metadataWarnings = $metadata['warnings'] ?? []; + if ($policy->policy_type === 'windowsUpdateRing') { + [$payload, $metadata] = $this->hydrateWindowsUpdateRing( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + policyId: $policy->external_id, + payload: is_array($payload) ? $payload : [], + metadata: $metadata, + ); + } + if ($policy->policy_type === 'settingsCatalogPolicy') { [$payload, $metadata] = $this->hydrateSettingsCatalog( tenantIdentifier: $tenantIdentifier, @@ -152,6 +162,57 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + /** + * Hydrate Windows Update Ring payload via derived type cast to capture + * windowsUpdateForBusinessConfiguration-specific properties. + * + * @return array{0:array,1:array} + */ + private function hydrateWindowsUpdateRing(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + { + $odataType = $payload['@odata.type'] ?? null; + $castSegment = $this->deriveTypeCastSegment($odataType); + + if ($castSegment === null) { + $metadata['properties_hydration'] = 'skipped'; + + return [$payload, $metadata]; + } + + $castPath = sprintf('deviceManagement/deviceConfigurations/%s/%s', urlencode($policyId), $castSegment); + + $response = $this->graphClient->request('GET', $castPath, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]); + + if ($response->failed() || ! is_array($response->data)) { + $metadata['properties_hydration'] = 'failed'; + + return [$payload, $metadata]; + } + + $metadata['properties_hydration'] = 'complete'; + + return [array_merge($payload, $response->data), $metadata]; + } + + private function deriveTypeCastSegment(mixed $odataType): ?string + { + if (! is_string($odataType) || $odataType === '') { + return null; + } + + if (! str_starts_with($odataType, '#')) { + return null; + } + + $segment = ltrim($odataType, '#'); + + return $segment !== '' ? $segment : null; + } + private function isMetadataOnlyPolicyType(string $policyType): bool { foreach (config('tenantpilot.supported_policy_types', []) as $type) { diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index e9e02d1..85f23fe 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -555,6 +555,23 @@ public function execute( $payload, $graphOptions + ['method' => $updateMethod] ); + } elseif ($item->policy_type === 'windowsUpdateRing') { + $odataType = $this->resolvePayloadString($originalPayload, ['@odata.type']); + $castSegment = $odataType && str_starts_with($odataType, '#') + ? ltrim($odataType, '#') + : 'microsoft.graph.windowsUpdateForBusinessConfiguration'; + + $updatePath = sprintf( + 'deviceManagement/deviceConfigurations/%s/%s', + urlencode($item->policy_identifier), + $castSegment, + ); + + $response = $this->graphClient->request( + $updateMethod, + $updatePath, + ['json' => $payload] + Arr::except($graphOptions, ['platform']) + ); } else { $response = $this->graphClient->applyPolicy( $item->policy_type, diff --git a/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php b/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php new file mode 100644 index 0000000..4e95d52 --- /dev/null +++ b/app/Services/Intune/WindowsFeatureUpdateProfileNormalizer.php @@ -0,0 +1,107 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'][] = $this->buildFeatureUpdateBlock($snapshot); + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildFeatureUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $version = Arr::get($snapshot, 'featureUpdateVersion'); + + if (is_string($version) && $version !== '') { + $entries[] = ['key' => 'Feature update version', 'value' => $version]; + } + + $rollout = Arr::get($snapshot, 'rolloutSettings'); + + if (is_array($rollout)) { + $start = $this->formatDateTime($rollout['offerStartDateTimeInUTC'] ?? null); + $end = $this->formatDateTime($rollout['offerEndDateTimeInUTC'] ?? null); + $interval = $rollout['offerIntervalInDays'] ?? null; + + if ($start !== null) { + $entries[] = ['key' => 'Rollout start', 'value' => $start]; + } + + if ($end !== null) { + $entries[] = ['key' => 'Rollout end', 'value' => $end]; + } + + if ($interval !== null) { + $entries[] = ['key' => 'Rollout interval (days)', 'value' => $interval]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Feature Update Profile', + 'entries' => $entries, + ]; + } + + private function formatDateTime(mixed $value): ?string + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->toDateTimeString(); + } catch (\Throwable) { + return $value; + } + } +} diff --git a/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php b/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php new file mode 100644 index 0000000..b1ff849 --- /dev/null +++ b/app/Services/Intune/WindowsQualityUpdateProfileNormalizer.php @@ -0,0 +1,83 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $block = $this->buildQualityUpdateBlock($snapshot); + + if ($block !== null) { + $normalized['settings'][] = $block; + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildQualityUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $release = Arr::get($snapshot, 'releaseDateDisplayName'); + + if (is_string($release) && $release !== '') { + $entries[] = ['key' => 'Release', 'value' => $release]; + } + + $content = Arr::get($snapshot, 'deployableContentDisplayName'); + + if (is_string($content) && $content !== '') { + $entries[] = ['key' => 'Deployable content', 'value' => $content]; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Quality Update Profile', + 'entries' => $entries, + ]; + } +} diff --git a/app/Services/Intune/WindowsUpdateRingNormalizer.php b/app/Services/Intune/WindowsUpdateRingNormalizer.php new file mode 100644 index 0000000..66ec7a0 --- /dev/null +++ b/app/Services/Intune/WindowsUpdateRingNormalizer.php @@ -0,0 +1,137 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + fn (array $block) => strtolower((string) ($block['title'] ?? '')) !== 'general' + )); + + $normalized['settings'][] = $this->buildUpdateSettingsBlock($snapshot); + $normalized['settings'][] = $this->buildUserExperienceBlock($snapshot); + $normalized['settings'][] = $this->buildAdvancedOptionsBlock($snapshot); + + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildUpdateSettingsBlock(array $snapshot): ?array + { + $keys = [ + 'allowWindows11Upgrade', + 'automaticUpdateMode', + 'featureUpdatesDeferralPeriodInDays', + 'featureUpdatesPaused', + 'featureUpdatesPauseExpiryDateTime', + 'qualityUpdatesDeferralPeriodInDays', + 'qualityUpdatesPaused', + 'qualityUpdatesPauseExpiryDateTime', + 'updateWindowsDeviceDriverExclusion', + ]; + + return $this->buildBlock('Update Settings', $snapshot, $keys); + } + + private function buildUserExperienceBlock(array $snapshot): ?array + { + $keys = [ + 'deadlineForFeatureUpdatesInDays', + 'deadlineForQualityUpdatesInDays', + 'deadlineGracePeriodInDays', + 'gracePeriodInDays', + 'restartActiveHoursStart', + 'restartActiveHoursEnd', + 'setActiveHours', + 'userPauseAccess', + 'userCheckAccess', + ]; + + return $this->buildBlock('User Experience', $snapshot, $keys); + } + + private function buildAdvancedOptionsBlock(array $snapshot): ?array + { + $keys = [ + 'deliveryOptimizationMode', + 'prereleaseFeatures', + 'servicingChannel', + 'microsoftUpdateServiceAllowed', + ]; + + return $this->buildBlock('Advanced Options', $snapshot, $keys); + } + + private function buildBlock(string $title, array $snapshot, array $keys): ?array + { + $entries = []; + + foreach ($keys as $key) { + if (array_key_exists($key, $snapshot)) { + $entries[] = [ + 'key' => Str::headline($key), + 'value' => $this->formatValue($snapshot[$key]), + ]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => $title, + 'entries' => $entries, + ]; + } + + private function formatValue(mixed $value): mixed + { + if (is_bool($value)) { + return $value ? 'Yes' : 'No'; + } + + if (is_array($value)) { + return json_encode($value, JSON_PRETTY_PRINT); + } + + return $value; + } +} diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index eb5274a..37ba8dc 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -29,6 +29,14 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', 'all' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', ], + 'windowsFeatureUpdateProfile' => [ + 'windows' => '#microsoft.graph.windowsFeatureUpdateProfile', + 'all' => '#microsoft.graph.windowsFeatureUpdateProfile', + ], + 'windowsQualityUpdateProfile' => [ + 'windows' => '#microsoft.graph.windowsQualityUpdateProfile', + 'all' => '#microsoft.graph.windowsQualityUpdateProfile', + ], 'deviceCompliancePolicy' => [ 'windows' => '#microsoft.graph.windows10CompliancePolicy', 'ios' => '#microsoft.graph.iosCompliancePolicy', diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 6c6ac67..2b877e9 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -143,6 +143,13 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'version', + 'qualityUpdatesPauseStartDate', + 'featureUpdatesPauseStartDate', + 'qualityUpdatesWillBeRolledBack', + 'featureUpdatesWillBeRolledBack', + ], 'assignments_list_path' => '/deviceManagement/deviceConfigurations/{id}/assignments', 'assignments_create_path' => '/deviceManagement/deviceConfigurations/{id}/assign', 'assignments_create_method' => 'POST', @@ -153,6 +160,52 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'windowsFeatureUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsFeatureUpdateProfiles', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsFeatureUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'deployableContentDisplayName', + 'endOfSupportDate', + ], + 'assignments_list_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsFeatureUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], + 'windowsQualityUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsQualityUpdateProfiles', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsQualityUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'releaseDateDisplayName', + 'deployableContentDisplayName', + ], + 'assignments_list_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 1e214df..2e8c4b0 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -8,7 +8,7 @@ 'category' => 'Configuration', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceConfigurations', - 'filter' => "@odata.type ne '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'filter' => "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -39,11 +39,31 @@ 'category' => 'Update Management', 'platform' => 'windows', 'endpoint' => 'deviceManagement/deviceConfigurations', - 'filter' => "@odata.type eq '#microsoft.graph.windowsUpdateForBusinessConfiguration'", + 'filter' => "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium-high', ], + [ + 'type' => 'windowsFeatureUpdateProfile', + 'label' => 'Feature Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsFeatureUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], + [ + 'type' => 'windowsQualityUpdateProfile', + 'label' => 'Quality Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsQualityUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', @@ -130,7 +150,7 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "@odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'", + 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', diff --git a/phpunit.xml b/phpunit.xml index d703241..75c4ea3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,7 +18,9 @@ + + diff --git a/specs/012-windows-update-rings/plan.md b/specs/012-windows-update-rings/plan.md new file mode 100644 index 0000000..624d738 --- /dev/null +++ b/specs/012-windows-update-rings/plan.md @@ -0,0 +1,18 @@ +# Implementation Plan: Windows Update Rings (012) + +**Branch**: `feat/012-windows-update-rings` +**Date**: 2025-12-31 +**Spec Source**: [spec.md](./spec.md) + +## Summary +Make `windowsUpdateRing` snapshots/restores accurate by correctly capturing and applying its settings, and present a readable normalized view in Filament. + +Also add coverage for Windows Feature Update Profiles (`windowsFeatureUpdateProfile`) and Windows Quality Update Profiles (`windowsQualityUpdateProfile`) so they can be synced, snapshotted, restored, and displayed in a readable normalized format. + +## Execution Steps +1. **Graph contract verification**: Ensure `config/graph_contracts.php` entry for `windowsUpdateRing` is correct and complete. +2. **Snapshot capture hydration**: Extend `PolicySnapshotService` to correctly hydrate `windowsUpdateForBusinessConfiguration` settings into the policy payload. +3. **Restore**: Extend `RestoreService` to apply `windowsUpdateRing` settings from a snapshot to the target policy in Intune. +4. **UI normalization**: Add a dedicated normalizer for `windowsUpdateRing` that renders configured settings as readable rows in the Filament UI. +5. **Feature/Quality Update Profiles**: Add Graph contract + supported types, and normalizers for `windowsFeatureUpdateProfile` and `windowsQualityUpdateProfile`. +6. **Tests + formatting**: Add targeted Pest tests for sync filters/types, snapshot/normalized display (as applicable), and restore payload sanitization. Run `./vendor/bin/pint --dirty` and the affected tests. diff --git a/specs/012-windows-update-rings/spec.md b/specs/012-windows-update-rings/spec.md new file mode 100644 index 0000000..1f05b92 --- /dev/null +++ b/specs/012-windows-update-rings/spec.md @@ -0,0 +1,77 @@ +# Feature Specification: Windows Update Rings (012) + +**Feature Branch**: `feat/012-windows-update-rings` +**Created**: 2025-12-31 +**Status**: Draft +**Input**: `config/graph_contracts.php` (windowsUpdateRing scope) + +## Overview +Add reliable coverage for **Windows Update Rings** (`windowsUpdateRing`) in the existing inventory/backup/version/restore flows. + +This feature also extends coverage to **Windows Feature Update Profiles** ("Feature Updates"), which are managed under the `deviceManagement/windowsFeatureUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsFeatureUpdateProfile`. + +This feature also extends coverage to **Windows Quality Update Profiles** ("Quality Updates"), which are managed under the `deviceManagement/windowsQualityUpdateProfiles` endpoint and have `@odata.type` `#microsoft.graph.windowsQualityUpdateProfile`. + +This policy type is defined in `graph_contracts.php` and uses the `deviceManagement/deviceConfigurations` endpoint, identified by the `@odata.type` `#microsoft.graph.windowsUpdateForBusinessConfiguration`. This feature will focus on implementing the necessary UI normalization and ensuring the sync, backup, versioning, and restore flows function correctly for this policy type. + +## In Scope +- Policy type: `windowsUpdateRing` +- Sync: Policies with `@odata.type` of `#microsoft.graph.windowsUpdateForBusinessConfiguration` should be correctly identified and synced as `windowsUpdateRing` policies. +- Snapshot capture: Full snapshot of all settings within a Windows Update Ring policy. +- Restore: Restore a Windows Update Ring policy from a snapshot. +- UI: Display the settings of a Windows Update Ring policy in a readable, normalized format. + +- Policy type: `windowsFeatureUpdateProfile` +- Sync: Feature Update Profiles should be listed and synced from `deviceManagement/windowsFeatureUpdateProfiles`. +- Snapshot capture: Full snapshot of the Feature Update Profile payload. +- Restore: Restore a Feature Update Profile from a snapshot. +- UI: Display the key settings of a Feature Update Profile in a readable, normalized format. + +- Policy type: `windowsQualityUpdateProfile` +- Sync: Quality Update Profiles should be listed and synced from `deviceManagement/windowsQualityUpdateProfiles`. +- Snapshot capture: Full snapshot of the Quality Update Profile payload. +- Restore: Restore a Quality Update Profile from a snapshot. +- UI: Display the key settings of a Quality Update Profile in a readable, normalized format. + +## Out of Scope (v1) +- Advanced analytics or reporting on update compliance. +- Per-setting partial restore. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Inventory + readable view +As an admin, I can see my Windows Update Ring policies in the policy list and view their configured settings in a clear, understandable format. + +**Acceptance** +1. Windows Update Ring policies are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., "Quality update deferral period", "Automatic update behavior"). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. + +### User Story 2 — Backup/Version capture +As an admin, when I back up or create a new version of a Windows Update Ring policy, the snapshot contains all its settings. + +**Acceptance** +1. The backup/version payload in the `snapshot` column contains all the properties of the `windowsUpdateForBusinessConfiguration` object. + +### User Story 3 — Restore settings +As an admin, I can restore a Windows Update Ring policy from a backup or a previous version. + +**Acceptance** +1. The restore operation correctly applies the settings from the snapshot to the target policy in Intune. +2. The restore process is audited. + +### User Story 4 — Feature Updates inventory + readable view +As an admin, I can see my Windows Feature Update Profiles in the policy list and view their configured rollout/version settings in a clear, understandable format. + +**Acceptance** +1. Feature Update Profiles are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., feature update version, rollout window). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. + +### User Story 5 — Quality Updates inventory + readable view +As an admin, I can see my Windows Quality Update Profiles in the policy list and view their configured release/content settings in a clear, understandable format. + +**Acceptance** +1. Quality Update Profiles are listed in the main policy table with the correct type name. +2. The policy detail view shows a structured list/table of configured settings (e.g., release, deployable content). +3. Policy Versions store the snapshot and render the settings in the “Normalized settings” view. diff --git a/specs/012-windows-update-rings/tasks.md b/specs/012-windows-update-rings/tasks.md new file mode 100644 index 0000000..ed5d9a6 --- /dev/null +++ b/specs/012-windows-update-rings/tasks.md @@ -0,0 +1,26 @@ +# Tasks: Windows Update Rings (012) + +**Branch**: `feat/012-windows-update-rings` | **Date**: 2025-12-31 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts + Snapshot Hydration +- [X] T001 Verify `config/graph_contracts.php` for `windowsUpdateRing` (resource, allowed_select, type_family, etc.). +- [X] T002 Extend `PolicySnapshotService` to hydrate `windowsUpdateForBusinessConfiguration` settings. +- [X] T001b Fix Graph filters for update rings (`isof(...)`) and add `windowsFeatureUpdateProfile` support. + +## Phase 2: Restore +- [X] T003 Implement restore apply for `windowsUpdateRing` settings in `RestoreService.php`. + +## Phase 3: UI Normalization +- [X] T004 Add `WindowsUpdateRingNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004b Add `WindowsFeatureUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable). +- [X] T004c Add `WindowsQualityUpdateProfileNormalizer` and register it (Policy “Normalized settings” is readable). + +## Phase 4: Tests + Verification +- [X] T005 Add tests for sync filters + supported types. +- [X] T006 Add tests for restore apply. +- [X] T007 Run tests (targeted). +- [X] T008 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None yet. diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index d01960e..19395e6 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -11,6 +11,7 @@ test('progress widget shows running operations for current tenant and user', function () { $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); $user = User::factory()->create(); // Own running op @@ -39,9 +40,6 @@ 'status' => 'running', ]); - // $tenant->makeCurrent(); - $tenant->forceFill(['is_current' => true])->save(); - auth()->login($user); // Login user explicitly for auth()->id() call in component Livewire::actingAs($user) diff --git a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php index c214e10..809b9ed 100644 --- a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php @@ -12,7 +12,7 @@ test('policy detail shows app protection settings in readable sections', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index e619c15..728a913 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -73,7 +73,7 @@ public function request(string $method, string $path, array $options = []): Grap }); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index 9693d8b..fb2a62e 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -23,6 +23,8 @@ 'name' => 'Tenant', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set 1', @@ -60,6 +62,8 @@ 'name' => 'Tenant 2', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set with restore', @@ -93,6 +97,8 @@ 'name' => 'Tenant Force', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set force', @@ -132,6 +138,8 @@ 'name' => 'Tenant Restore Backup Set', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set restore', @@ -171,6 +179,8 @@ 'name' => 'Tenant Restore Run', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set RR', @@ -207,6 +217,8 @@ 'name' => 'Tenant Restore Restore Run', ]); + $tenant->makeCurrent(); + $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => 'Set for restore run restore', diff --git a/tests/Feature/Filament/MalformedSnapshotWarningTest.php b/tests/Feature/Filament/MalformedSnapshotWarningTest.php index 9b436b5..07bcea1 100644 --- a/tests/Feature/Filament/MalformedSnapshotWarningTest.php +++ b/tests/Feature/Filament/MalformedSnapshotWarningTest.php @@ -13,7 +13,7 @@ test('malformed snapshot renders warning on policy and version detail', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 0487693..3aca519 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -50,7 +50,7 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon }); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 6099fcb..72ba98f 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -8,7 +8,7 @@ test('policies are listed for the active tenant', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); diff --git a/tests/Feature/Filament/PolicySettingsDisplayTest.php b/tests/Feature/Filament/PolicySettingsDisplayTest.php index 8005eaa..56acd96 100644 --- a/tests/Feature/Filament/PolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/PolicySettingsDisplayTest.php @@ -12,13 +12,12 @@ test('policy detail shows normalized settings section', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php index c9181c2..e53e7ab 100644 --- a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php +++ b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php @@ -12,13 +12,12 @@ test('policy version detail renders tabs and scroll-safe blocks', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php index 6a9d3e3..63a3dce 100644 --- a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php +++ b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php @@ -12,13 +12,12 @@ test('policy version view shows scope tags even when assignments are missing', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index 2f25d7d..56af173 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -12,13 +12,12 @@ test('policy version detail shows raw and normalized settings', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/PolicyVersionTest.php b/tests/Feature/Filament/PolicyVersionTest.php index 55aed75..ed14f79 100644 --- a/tests/Feature/Filament/PolicyVersionTest.php +++ b/tests/Feature/Filament/PolicyVersionTest.php @@ -11,11 +11,13 @@ test('policy versions render with timeline data', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], ]); + $tenant->makeCurrent(); + $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'policy-1', diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 45ec5a2..49b7cbd 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -13,13 +13,12 @@ it('shows Settings tab for Settings Catalog policy', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -86,13 +85,12 @@ it('shows display names instead of definition IDs', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -143,13 +141,12 @@ it('shows fallback prettified labels when definitions not cached', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -195,13 +192,12 @@ it('shows tabbed layout for non-Settings Catalog policies', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ @@ -242,7 +238,7 @@ // T034: Test display names shown (not definition IDs) it('displays setting display names instead of raw definition IDs', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -296,7 +292,7 @@ // T035: Test values formatted correctly it('formats setting values correctly based on type', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -370,7 +366,7 @@ // T036: Test search/filter functionality it('search filters settings in real-time', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); @@ -433,7 +429,7 @@ // T037: Test graceful degradation for missing definitions it('shows prettified fallback labels when definitions are not cached', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Test Tenant', 'is_current' => true, ]); diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index fe896c0..2505927 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -13,13 +13,12 @@ test('settings catalog policies render a normalized settings table', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index 7f38369..f34e7c3 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -75,16 +75,11 @@ public function request(string $method, string $path, array $options = []): Grap app()->instance(GraphClientInterface::class, new SettingsCatalogFakeGraphClient($responses)); $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); - $_ENV['INTUNE_TENANT_ID'] = $tenant->tenant_id; - $_SERVER['INTUNE_TENANT_ID'] = $tenant->tenant_id; - $tenant->makeCurrent(); expect(Tenant::current()->id)->toBe($tenant->id); diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index 9ee2573..ddc67fb 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -13,13 +13,12 @@ test('settings catalog settings render as a filament table with details action', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'local-tenant'), + 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', 'metadata' => [], 'is_current' => true, ]); - putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); $policy = Policy::create([ diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php new file mode 100644 index 0000000..fcbf824 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -0,0 +1,213 @@ + + */ + public array $applyPolicyCalls = []; + + 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->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore execution applies windows feature update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-feature', + 'policy_type' => 'windowsFeatureUpdateProfile', + 'display_name' => 'Feature Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-feature', + '@odata.type' => '#microsoft.graph.windowsFeatureUpdateProfile', + 'displayName' => 'Feature Updates A', + 'featureUpdateVersion' => 'Windows 11, version 23H2', + 'deployableContentDisplayName' => 'Some Content', + 'endOfSupportDate' => '2026-01-01T00:00:00Z', + 'roleScopeTagIds' => ['0'], + ]; + + $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' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsFeatureUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-feature'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['featureUpdateVersion'])->toBe('Windows 11, version 23H2'); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('endOfSupportDate'); +}); + +test('restore execution applies windows quality update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-quality', + 'policy_type' => 'windowsQualityUpdateProfile', + 'display_name' => 'Quality Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-quality', + '@odata.type' => '#microsoft.graph.windowsQualityUpdateProfile', + 'displayName' => 'Quality Updates A', + 'qualityUpdateCveIds' => ['CVE-2025-0001'], + 'deployableContentDisplayName' => 'Some Content', + 'releaseDateDisplayName' => 'January 2026', + 'roleScopeTagIds' => ['0'], + ]; + + $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' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsQualityUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-quality'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['qualityUpdateCveIds'])->toBe(['CVE-2025-0001']); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php new file mode 100644 index 0000000..fccc528 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php @@ -0,0 +1,77 @@ + 'local-tenant', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + 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' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'deadlineForFeatureUpdatesInDays' => 7, + 'deliveryOptimizationMode' => 'httpWithPeeringNat', + 'qualityUpdatesPaused' => false, + 'userPauseAccess' => 'allow', + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + + // Check for correct titles and settings from the normalizer + $response->assertSee('Update Settings'); + $response->assertSee('Automatic Update Mode'); + $response->assertSee('autoInstallAtMaintenanceTime'); + $response->assertSee('Feature Updates Deferral Period In Days'); + $response->assertSee('14'); + $response->assertSee('Quality Updates Paused'); + $response->assertSee('No'); + + $response->assertSee('User Experience'); + $response->assertSee('Deadline For Feature Updates In Days'); + $response->assertSee('7'); + $response->assertSee('User Pause Access'); + $response->assertSee('allow'); + + $response->assertSee('Advanced Options'); + $response->assertSee('Delivery Optimization Mode'); + $response->assertSee('httpWithPeeringNat'); + + // $response->assertDontSee('@odata.type'); +}); diff --git a/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php new file mode 100644 index 0000000..441dc24 --- /dev/null +++ b/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -0,0 +1,151 @@ + []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->applied[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return new GraphResponse(true, []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }; + + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Windows Update Ring A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-wuring', + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + 'version' => 7, + 'qualityUpdatesPauseStartDate' => '2025-01-01T00:00:00Z', + 'featureUpdatesPauseStartDate' => '2025-01-02T00:00:00Z', + 'qualityUpdatesWillBeRolledBack' => false, + 'featureUpdatesWillBeRolledBack' => false, + 'roleScopeTagIds' => ['0'], + ]; + + $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' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + $this->assertDatabaseHas('audit_logs', [ + 'action' => 'restore.executed', + 'resource_id' => (string) $run->id, + ]); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->requests)->toHaveCount(1); + expect($client->requests[0]['method'])->toBe('PATCH'); + expect($client->requests[0]['path'])->toBe('deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration'); + + expect($client->requests[0]['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); + expect($client->requests[0]['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); + expect($client->requests[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->requests[0]['payload'])->not->toHaveKey('id'); + expect($client->requests[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->requests[0]['payload'])->not->toHaveKey('version'); + expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesPauseStartDate'); + expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesPauseStartDate'); + expect($client->requests[0]['payload'])->not->toHaveKey('qualityUpdatesWillBeRolledBack'); + expect($client->requests[0]['payload'])->not->toHaveKey('featureUpdatesWillBeRolledBack'); +}); diff --git a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php index 8b4a56f..fb3dcb1 100644 --- a/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php +++ b/tests/Feature/Jobs/AppProtectionPolicySyncFilteringTest.php @@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync skips managed app configurations from app protection inventory', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'tenant_id' => 'test-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php index d6a50ab..4ae8c1f 100644 --- a/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php +++ b/tests/Feature/Jobs/PolicySyncIgnoredRevivalTest.php @@ -49,7 +49,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync revives ignored policies when they exist in Intune', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant'), + 'tenant_id' => 'test-tenant', 'name' => 'Test Tenant', 'metadata' => [], 'is_current' => true, @@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap test('sync creates new policies even if ignored ones exist with same external_id', function () { $tenant = Tenant::create([ - 'tenant_id' => env('INTUNE_TENANT_ID', 'test-tenant-2'), + 'tenant_id' => 'test-tenant-2', 'name' => 'Test Tenant 2', 'metadata' => [], 'is_current' => true, diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php new file mode 100644 index 0000000..ef6d674 --- /dev/null +++ b/tests/Feature/PolicySyncServiceTest.php @@ -0,0 +1,77 @@ +create([ + 'status' => 'active', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'appProtectionPolicy', + 'ignored_at' => null, + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'policy-1', + 'displayName' => 'Ignored policy', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $synced = $service->syncPolicies($tenant, [ + ['type' => 'appProtectionPolicy'], + ]); + + $policy->refresh(); + + expect($policy->ignored_at)->not->toBeNull(); + expect($synced)->toBeArray()->toBeEmpty(); +}); + +it('uses isof filters for windows update rings and supports feature/quality update profiles', function () { + $supported = config('tenantpilot.supported_policy_types'); + $byType = collect($supported)->keyBy('type'); + + expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']); + + expect($byType['deviceConfiguration']['filter'] ?? null) + ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); + + expect($byType['windowsUpdateRing']['filter'] ?? null) + ->toBe("isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); + + expect($byType['windowsFeatureUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsFeatureUpdateProfiles'); + + expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsQualityUpdateProfiles'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index ee63ad0..808b72f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,4 +4,13 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -abstract class TestCase extends BaseTestCase {} +abstract class TestCase extends BaseTestCase +{ + protected function setUp(): void + { + parent::setUp(); + + putenv('INTUNE_TENANT_ID'); + unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); + } +} diff --git a/tests/Unit/FoundationSnapshotServiceTest.php b/tests/Unit/FoundationSnapshotServiceTest.php index bcd1f8d..e9c4c84 100644 --- a/tests/Unit/FoundationSnapshotServiceTest.php +++ b/tests/Unit/FoundationSnapshotServiceTest.php @@ -115,7 +115,7 @@ public function request(string $method, string $path, array $options = []): Grap expect($result['items'][1]['source_id'])->toBe('filter-2'); expect($client->requests[0]['path'])->toBe('deviceManagement/assignmentFilters'); - expect($client->requests[0]['options']['query']['$select'])->toBe(['id', 'displayName']); + expect($client->requests[0]['options']['query']['$select'])->toBe('id,displayName'); expect($client->requests[1]['path'])->toBe('deviceManagement/assignmentFilters?$skiptoken=abc'); expect($client->requests[1]['options']['query'])->toBe([]); }); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9fa44ae..2bea6c1 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -169,3 +169,85 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->toContain('roleScopeTagIds'); expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); + +class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + '@odata.type' => '#microsoft.graph.windowsUpdateForBusinessConfiguration', + 'displayName' => 'Ring A', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path]; + + if ($method === 'GET' && $path === 'deviceManagement/deviceConfigurations/policy-wuring/microsoft.graph.windowsUpdateForBusinessConfiguration') { + return new GraphResponse(success: true, data: [ + 'automaticUpdateMode' => 'autoInstallAtMaintenanceTime', + 'featureUpdatesDeferralPeriodInDays' => 14, + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + +it('hydrates windows update ring snapshots via derived type cast endpoint', function () { + $client = new WindowsUpdateRingSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-wuring', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-wuring', + 'policy_type' => 'windowsUpdateRing', + 'display_name' => 'Ring A', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.windowsUpdateForBusinessConfiguration'); + expect($result['payload']['automaticUpdateMode'])->toBe('autoInstallAtMaintenanceTime'); + expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); + expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete'); +}); diff --git a/tests/Unit/TenantCurrentTest.php b/tests/Unit/TenantCurrentTest.php index 462d952..86687c1 100644 --- a/tests/Unit/TenantCurrentTest.php +++ b/tests/Unit/TenantCurrentTest.php @@ -108,3 +108,27 @@ function restoreIntuneTenantId(string|false $original): void restoreIntuneTenantId($originalEnv); }); + +it('makeCurrent keeps tenant current when already current', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $current = Tenant::create([ + 'tenant_id' => 'tenant-current', + 'name' => 'Already Current', + 'is_current' => true, + ]); + + $other = Tenant::create([ + 'tenant_id' => 'tenant-other', + 'name' => 'Other Tenant', + 'is_current' => false, + ]); + + $current->makeCurrent(); + + expect($current->fresh()->is_current)->toBeTrue(); + expect($other->fresh()->is_current)->toBeFalse(); + + restoreIntuneTenantId($originalEnv); +}); From 4cdd09263772fab91febb32c8f32991c1bc26521 Mon Sep 17 00:00:00 2001 From: ahmido Date: Thu, 1 Jan 2026 22:02:30 +0000 Subject: [PATCH 03/18] 013-scripts-management (#19) Adds scripts normalizer + safe script content display (opt-in, decoded, capped) Improves script diff UX: side-by-side + Before/After, Torchlight highlighting, fullscreen with scroll-sync Fixes Torchlight dark mode in diff lines Tests updated/added; ScriptPoliciesNormalizedDisplayTest.php passes Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/19 --- app/Filament/Resources/PolicyResource.php | 1 + .../Resources/PolicyVersionResource.php | 6 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Graph/AssignmentFetcher.php | 28 +- .../Intune/ScriptsPolicyNormalizer.php | 248 +++++++ composer.json | 1 + composer.lock | 193 ++++- config/tenantpilot.php | 5 + .../entries/normalized-diff.blade.php | 664 +++++++++++++++++- .../policy-settings-standard.blade.php | 110 ++- .../torchlight-dark-overrides.blade.php | 13 + .../checklists/requirements.md | 34 + specs/013-scripts-management/plan.md | 42 ++ specs/013-scripts-management/spec.md | 112 +++ specs/013-scripts-management/tasks.md | 28 + .../ScriptPoliciesNormalizedDisplayTest.php | 128 ++++ tests/Unit/AssignmentFetcherTest.php | 50 +- tests/Unit/ScriptsPolicyNormalizerTest.php | 168 +++++ 18 files changed, 1773 insertions(+), 60 deletions(-) create mode 100644 app/Services/Intune/ScriptsPolicyNormalizer.php create mode 100644 resources/views/filament/partials/torchlight-dark-overrides.blade.php create mode 100644 specs/013-scripts-management/checklists/requirements.md create mode 100644 specs/013-scripts-management/plan.md create mode 100644 specs/013-scripts-management/spec.md create mode 100644 specs/013-scripts-management/tasks.md create mode 100644 tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php create mode 100644 tests/Unit/ScriptsPolicyNormalizerTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bea7788..bbfac49 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -623,6 +623,7 @@ private static function normalizedPolicyState(Policy $record): array $normalized['context'] = 'policy'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; $request->attributes->set($cacheKey, $normalized); diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 4bab649..2b01621 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -87,6 +87,7 @@ public static function infolist(Schema $schema): Schema $normalized['context'] = 'version'; $normalized['record_id'] = (string) $record->getKey(); + $normalized['policy_type'] = $record->policy_type; return $normalized; }) @@ -114,7 +115,10 @@ public static function infolist(Schema $schema): Schema : []; $to = $normalizer->flattenForDiff($record->snapshot ?? [], $record->policy_type ?? '', $record->platform); - return $diff->compare($from, $to); + $result = $diff->compare($from, $to); + $result['policy_type'] = $record->policy_type; + + return $result; }), Infolists\Components\ViewEntry::make('diff_json') ->label('Raw diff (advanced)') diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dbf8384..f6421d8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; +use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; @@ -42,6 +43,7 @@ public function register(): void CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, + ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 6bd2139..29aa0b5 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -39,7 +39,7 @@ public function fetch( $primaryException = null; $assignments = []; - $primarySucceeded = false; + $lastSuccessfulAssignments = null; // Try primary endpoint(s) $listPathTemplates = []; @@ -65,7 +65,12 @@ public function fetch( $context, $throwOnFailure ); - $primarySucceeded = true; + + if ($assignments === null) { + continue; + } + + $lastSuccessfulAssignments = $assignments; if (! empty($assignments)) { Log::debug('Fetched assignments via primary endpoint', [ @@ -77,20 +82,25 @@ public function fetch( return $assignments; } + + if ($policyType !== 'appProtectionPolicy') { + // Empty is a valid outcome (policy not assigned). Do not attempt fallback. + return []; + } } catch (GraphException $e) { $primaryException = $primaryException ?? $e; } } - if ($primarySucceeded && $policyType === 'appProtectionPolicy') { + if ($lastSuccessfulAssignments !== null && $policyType === 'appProtectionPolicy') { Log::debug('Assignments fetched via primary endpoint(s)', [ 'tenant_id' => $tenantId, 'policy_type' => $policyType, 'policy_id' => $policyId, - 'count' => count($assignments), + 'count' => count($lastSuccessfulAssignments), ]); - return $assignments; + return $lastSuccessfulAssignments; } // Try fallback with $expand @@ -215,15 +225,15 @@ private function fetchPrimary( array $options, array $context, bool $throwOnFailure - ): array { + ): ?array { if (! is_string($listPathTemplate) || $listPathTemplate === '') { - return []; + return null; } $path = $this->resolvePath($listPathTemplate, $policyId); if ($path === null) { - return []; + return null; } $response = $this->graphClient->request('GET', $path, $options); @@ -239,7 +249,7 @@ private function fetchPrimary( ); } - return []; + return null; } return $response->data['value'] ?? []; diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php new file mode 100644 index 0000000..68bf7e5 --- /dev/null +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -0,0 +1,248 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $entries = []; + + $entries[] = ['key' => 'Type', 'value' => $policyType]; + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $entries[] = ['key' => 'Description', 'value' => $description]; + } + + $entries = array_merge($entries, $this->contentEntries($snapshot)); + + $schedule = Arr::get($snapshot, 'runSchedule'); + if (is_array($schedule) && $schedule !== []) { + $entries[] = ['key' => 'Run schedule', 'value' => Arr::except($schedule, ['@odata.type'])]; + } + + $frequency = Arr::get($snapshot, 'runFrequency'); + if (is_string($frequency) && $frequency !== '') { + $entries[] = ['key' => 'Run frequency', 'value' => $frequency]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $entries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + return [ + 'status' => 'ok', + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Script settings', + 'entries' => $entries, + ], + ], + 'warnings' => [], + ]; + } + + /** + * @return array + */ + private function contentEntries(array $snapshot): array + { + $showContent = (bool) config('tenantpilot.display.show_script_content', false); + $maxChars = (int) config('tenantpilot.display.max_script_content_chars', 5000); + if ($maxChars <= 0) { + $maxChars = 5000; + } + + if (! $showContent) { + return $this->contentSummaryEntries($snapshot); + } + + $entries = []; + + $scriptContent = Arr::get($snapshot, 'scriptContent'); + if (is_string($scriptContent) && $scriptContent !== '') { + $decoded = $this->decodeIfBase64Text($scriptContent); + if (is_string($decoded) && $decoded !== '') { + $scriptContent = $decoded; + } + } + + if (! is_string($scriptContent) || $scriptContent === '') { + $scriptContentBase64 = Arr::get($snapshot, 'scriptContentBase64'); + if (is_string($scriptContentBase64) && $scriptContentBase64 !== '') { + $decoded = base64_decode($this->stripWhitespace($scriptContentBase64), true); + if (is_string($decoded) && $decoded !== '') { + $scriptContent = $this->normalizeDecodedText($decoded); + } + } + } + + if (is_string($scriptContent) && $scriptContent !== '') { + $entries[] = ['key' => 'scriptContent', 'value' => $this->limitContent($scriptContent, $maxChars)]; + } + + foreach (['detectionScriptContent', 'remediationScriptContent'] as $key) { + $value = Arr::get($snapshot, $key); + + if (! is_string($value) || $value === '') { + continue; + } + + $decoded = $this->decodeIfBase64Text($value); + if (is_string($decoded) && $decoded !== '') { + $value = $decoded; + } + + $entries[] = ['key' => $key, 'value' => $this->limitContent($value, $maxChars)]; + } + + return $entries; + } + + private function decodeIfBase64Text(string $candidate): ?string + { + $trimmed = $this->stripWhitespace($candidate); + if ($trimmed === '' || strlen($trimmed) < 16) { + return null; + } + + if (strlen($trimmed) % 4 !== 0) { + return null; + } + + if (! preg_match('/^[A-Za-z0-9+\/=]+$/', $trimmed)) { + return null; + } + + $decoded = base64_decode($trimmed, true); + if (! is_string($decoded) || $decoded === '') { + return null; + } + + $decoded = $this->normalizeDecodedText($decoded); + if ($decoded === '') { + return null; + } + + if (! $this->looksLikeText($decoded)) { + return null; + } + + return $decoded; + } + + private function stripWhitespace(string $value): string + { + return preg_replace('/\s+/', '', $value) ?? ''; + } + + private function normalizeDecodedText(string $decoded): string + { + if (str_starts_with($decoded, "\xFF\xFE")) { + $decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16LE'); + } elseif (str_starts_with($decoded, "\xFE\xFF")) { + $decoded = mb_convert_encoding(substr($decoded, 2), 'UTF-8', 'UTF-16BE'); + } elseif (str_contains($decoded, "\x00")) { + $decoded = mb_convert_encoding($decoded, 'UTF-8', 'UTF-16LE'); + } + + if (str_starts_with($decoded, "\xEF\xBB\xBF")) { + $decoded = substr($decoded, 3); + } + + return $decoded; + } + + private function looksLikeText(string $decoded): bool + { + $length = strlen($decoded); + if ($length === 0) { + return false; + } + + $nonPrintable = preg_match_all('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $decoded) ?: 0; + if ($nonPrintable > (int) max(1, $length * 0.05)) { + return false; + } + + // Scripts should typically contain some whitespace or line breaks. + if ($length >= 24 && ! preg_match('/\s/', $decoded)) { + return false; + } + + return true; + } + + /** + * @return array + */ + private function contentSummaryEntries(array $snapshot): array + { + // Script content and large blobs should not dominate normalized output. + // Keep only safe summary fields if present. + $contentKeys = [ + 'scriptContent', + 'scriptContentBase64', + 'detectionScriptContent', + 'remediationScriptContent', + ]; + + $entries = []; + + foreach ($contentKeys as $key) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $key, 'value' => sprintf('[content: %d chars]', strlen($value))]; + } + } + + return $entries; + } + + private function limitContent(string $content, int $maxChars): string + { + if (mb_strlen($content) <= $maxChars) { + return $content; + } + + return mb_substr($content, 0, $maxChars).'…'; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} diff --git a/composer.json b/composer.json index ccdbb83..b681b4b 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "require": { "php": "^8.2", "filament/filament": "^4.0", + "lara-zeus/torch-filament": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "pepperfm/filament-json": "^4" diff --git a/composer.lock b/composer.lock index 33f7c64..4a56c47 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4f08fd9fc4b86cc13b75332dd6e1b7a", + "content-hash": "20819254265bddd0aa70006919cb735f", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2082,6 +2082,87 @@ }, "time": "2025-11-13T14:57:49+00:00" }, + { + "name": "lara-zeus/torch-filament", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/lara-zeus/torch-filament.git", + "reference": "71dbe8df4a558a80308781ba20c5922943b33009" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lara-zeus/torch-filament/zipball/71dbe8df4a558a80308781ba20c5922943b33009", + "reference": "71dbe8df4a558a80308781ba20c5922943b33009", + "shasum": "" + }, + "require": { + "filament/filament": "^4.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.16", + "torchlight/engine": "^0.1.0" + }, + "require-dev": { + "larastan/larastan": "^2.0", + "laravel/pint": "^1.0", + "nunomaduro/collision": "^7.0", + "nunomaduro/phpinsights": "^2.8", + "orchestra/testbench": "^8.0", + "phpstan/extension-installer": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaraZeus\\TorchFilament\\TorchFilamentServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "LaraZeus\\TorchFilament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lara Zeus", + "email": "info@larazeus.com" + } + ], + "description": "Infolist component to highlight code using Torchlight Engine", + "homepage": "https://larazeus.com/torch-filament", + "keywords": [ + "code", + "design", + "engine", + "filamentphp", + "highlight", + "input", + "lara-zeus", + "laravel", + "torchlight", + "ui" + ], + "support": { + "issues": "https://github.com/lara-zeus/torch-filament/issues", + "source": "https://github.com/lara-zeus/torch-filament" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/larazeus", + "type": "custom" + }, + { + "url": "https://github.com/atmonshi", + "type": "github" + } + ], + "time": "2025-06-11T19:32:10+00:00" + }, { "name": "laravel/framework", "version": "v12.42.0", @@ -4265,6 +4346,60 @@ }, "time": "2025-02-26T00:08:40+00:00" }, + { + "name": "phiki/phiki", + "version": "v1.1.6", + "source": { + "type": "git", + "url": "https://github.com/phikiphp/phiki.git", + "reference": "3174d8cb309bdccc32b7a33500379de76148256b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phikiphp/phiki/zipball/3174d8cb309bdccc32b7a33500379de76148256b", + "reference": "3174d8cb309bdccc32b7a33500379de76148256b", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.5.3", + "php": "^8.2" + }, + "require-dev": { + "illuminate/support": "^11.30", + "laravel/pint": "^1.18.1", + "pestphp/pest": "^3.5.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^7.1.6" + }, + "bin": [ + "bin/phiki" + ], + "type": "library", + "autoload": { + "psr-4": { + "Phiki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ryan Chandler", + "email": "support@ryangjchandler.co.uk", + "homepage": "https://ryangjchandler.co.uk", + "role": "Developer" + } + ], + "description": "Syntax highlighting using TextMate grammars in PHP.", + "support": { + "issues": "https://github.com/phikiphp/phiki/issues", + "source": "https://github.com/phikiphp/phiki/tree/v1.1.6" + }, + "time": "2025-06-06T20:18:29+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -8110,6 +8245,62 @@ }, "time": "2024-12-21T16:25:41+00:00" }, + { + "name": "torchlight/engine", + "version": "v0.1.0", + "source": { + "type": "git", + "url": "https://github.com/torchlight-api/engine.git", + "reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/torchlight-api/engine/zipball/8d12f611efb0b22406ec0744abb453ddd2f1fe9d", + "reference": "8d12f611efb0b22406ec0744abb453ddd2f1fe9d", + "shasum": "" + }, + "require": { + "league/commonmark": "^2.5.3", + "phiki/phiki": "^1.1.4", + "php": "^8.2" + }, + "require-dev": { + "ext-dom": "*", + "ext-libxml": "*", + "laravel/pint": "^1.13", + "pestphp/pest": "^2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Torchlight\\Engine\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Francis", + "email": "aaron@hammerstone.dev" + }, + { + "name": "John Koster", + "email": "john@stillat.com" + } + ], + "description": "The PHP-based Torchlight code annotation and rendering engine.", + "keywords": [ + "Code highlighting", + "syntax highlighting" + ], + "support": { + "issues": "https://github.com/torchlight-api/engine/issues", + "source": "https://github.com/torchlight-api/engine/tree/v0.1.0" + }, + "time": "2025-04-02T01:47:48+00:00" + }, { "name": "ueberdosis/tiptap-php", "version": "2.0.0", diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 2e8c4b0..17efa20 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -218,4 +218,9 @@ 'chunk_size' => (int) env('TENANTPILOT_BULK_CHUNK_SIZE', 10), 'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3), ], + + 'display' => [ + 'show_script_content' => (bool) env('TENANTPILOT_SHOW_SCRIPT_CONTENT', false), + 'max_script_content_chars' => (int) env('TENANTPILOT_MAX_SCRIPT_CONTENT_CHARS', 5000), + ], ]; diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index c57ebd1..a7623b8 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -1,6 +1,7 @@ @php $diff = $getState() ?? ['summary' => [], 'added' => [], 'removed' => [], 'changed' => []]; $summary = $diff['summary'] ?? []; + $policyType = $diff['policy_type'] ?? null; $groupByBlock = static function (array $items): array { $groups = []; @@ -50,6 +51,180 @@ return is_string($value) && strlen($value) > 160; }; + + $isScriptKey = static function (mixed $name): bool { + return in_array((string) $name, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true); + }; + + $canHighlightScripts = static function (?string $policyType): bool { + return (bool) config('tenantpilot.display.show_script_content', false) + && in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript'], true); + }; + + $selectGrammar = static function (?string $policyType, string $code): string { + if ($policyType === 'deviceShellScript') { + $firstLine = strtok($code, "\n") ?: ''; + $shebang = trim($firstLine); + + if (str_starts_with($shebang, '#!')) { + if (str_contains($shebang, 'zsh')) { + return 'zsh'; + } + + if (str_contains($shebang, 'bash')) { + return 'bash'; + } + + return 'sh'; + } + + return 'sh'; + } + + return 'powershell'; + }; + + $highlight = static function (?string $policyType, string $code, string $fallbackClass = '') use ($selectGrammar): ?string { + if (! class_exists(\Torchlight\Engine\Engine::class)) { + return null; + } + + try { + return (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $selectGrammar($policyType, $code), + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: true, + ); + } catch (\Throwable $e) { + return null; + } + }; + + $highlightInline = static function (?string $policyType, string $code) use ($selectGrammar): ?string { + if (! class_exists(\Torchlight\Engine\Engine::class)) { + return null; + } + + if ($code === '') { + return ''; + } + + try { + $html = (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $selectGrammar($policyType, $code), + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: false, + ); + + $html = (string) preg_replace('//', '', $html); + + if (! preg_match('/]*>.*?<\\/code>/s', $html, $matches)) { + return null; + } + + return trim((string) ($matches[0] ?? '')); + } catch (\Throwable $e) { + return null; + } + }; + + $splitLines = static function (string $text): array { + $text = str_replace(["\r\n", "\r"], "\n", $text); + + return $text === '' ? [] : explode("\n", $text); + }; + + $myersLineDiff = static function (array $a, array $b): array { + $n = count($a); + $m = count($b); + $max = $n + $m; + + $v = [1 => 0]; + $trace = []; + + for ($d = 0; $d <= $max; $d++) { + $trace[$d] = $v; + + for ($k = -$d; $k <= $d; $k += 2) { + $kPlus = $v[$k + 1] ?? 0; + $kMinus = $v[$k - 1] ?? 0; + + if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) { + $x = $kPlus; + } else { + $x = $kMinus + 1; + } + + $y = $x - $k; + + while ($x < $n && $y < $m && $a[$x] === $b[$y]) { + $x++; + $y++; + } + + $v[$k] = $x; + + if ($x >= $n && $y >= $m) { + break 2; + } + } + } + + $ops = []; + $x = $n; + $y = $m; + + for ($d = count($trace) - 1; $d >= 0; $d--) { + $v = $trace[$d]; + $k = $x - $y; + + $kPlus = $v[$k + 1] ?? 0; + $kMinus = $v[$k - 1] ?? 0; + + if ($k === -$d || ($k !== $d && $kMinus < $kPlus)) { + $prevK = $k + 1; + } else { + $prevK = $k - 1; + } + + $prevX = $v[$prevK] ?? 0; + $prevY = $prevX - $prevK; + + while ($x > $prevX && $y > $prevY) { + $ops[] = ['type' => 'equal', 'line' => $a[$x - 1]]; + $x--; + $y--; + } + + if ($d === 0) { + break; + } + + if ($x === $prevX) { + $ops[] = ['type' => 'insert', 'line' => $b[$y - 1] ?? '']; + $y--; + } else { + $ops[] = ['type' => 'delete', 'line' => $a[$x - 1] ?? '']; + $x--; + } + } + + return array_reverse($ops); + }; + + $scriptLineDiff = static function (string $fromText, string $toText) use ($splitLines, $myersLineDiff): array { + return $myersLineDiff($splitLines($fromText), $splitLines($toText)); + }; @endphp
@@ -103,37 +278,467 @@ $to = $value['to']; $fromText = $stringify($from); $toText = $stringify($to); + + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $ops = $isScriptContent ? $scriptLineDiff((string) $fromText, (string) $toText) : []; + $useTorchlight = $isScriptContent && class_exists(\Torchlight\Engine\Engine::class); + + $rows = []; + if ($isScriptContent) { + $count = count($ops); + + for ($i = 0; $i < $count; $i++) { + $op = $ops[$i]; + $next = $ops[$i + 1] ?? null; + $type = $op['type'] ?? null; + $line = (string) ($op['line'] ?? ''); + + if ($type === 'equal') { + $rows[] = [ + 'left' => ['type' => 'equal', 'line' => $line], + 'right' => ['type' => 'equal', 'line' => $line], + ]; + continue; + } + + if ($type === 'delete' && is_array($next) && ($next['type'] ?? null) === 'insert') { + $rows[] = [ + 'left' => ['type' => 'delete', 'line' => $line], + 'right' => ['type' => 'insert', 'line' => (string) ($next['line'] ?? '')], + ]; + $i++; + continue; + } + + if ($type === 'delete') { + $rows[] = [ + 'left' => ['type' => 'delete', 'line' => $line], + 'right' => ['type' => 'blank', 'line' => ''], + ]; + continue; + } + + if ($type === 'insert') { + $rows[] = [ + 'left' => ['type' => 'blank', 'line' => ''], + 'right' => ['type' => 'insert', 'line' => $line], + ]; + continue; + } + } + } @endphp
{{ (string) $name }}
-
- From - @if ($isExpandable($from)) -
+ + @if ($isScriptContent) +
+ Script +
View -
{{ $fromText }}
+ +
+
+ + Diff + + + Before + + + After + + + + ⤢ Fullscreen + +
+ +
+
+
+
Old
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
+    $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
+
+    if ($leftType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
+    $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
+
+    if ($rightType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBefore = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBefore) && $highlightedBefore !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBefore !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfter = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfter) && $highlightedAfter !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfter !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+ +
+
+
+
+
Script diff
+
+ + Close + +
+
+ +
+
+
+ + Diff + + + Before + + + After + +
+ +
+
+
+
Old
+
@php
+foreach ($rows as $row) {
+    $left = $row['left'];
+    $leftType = $left['type'];
+    $leftLine = (string) ($left['line'] ?? '');
+
+    $leftHighlighted = $useTorchlight ? $highlightInline($policyType, $leftLine) : null;
+    $leftRendered = (is_string($leftHighlighted) && $leftHighlighted !== '') ? $leftHighlighted : e($leftLine);
+
+    if ($leftType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$leftRendered."\n";
+        continue;
+    }
+
+    if ($leftType === 'delete') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '- '.$leftRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+ +
+
New
+
@php
+foreach ($rows as $row) {
+    $right = $row['right'];
+    $rightType = $right['type'];
+    $rightLine = (string) ($right['line'] ?? '');
+
+    $rightHighlighted = $useTorchlight ? $highlightInline($policyType, $rightLine) : null;
+    $rightRendered = (is_string($rightHighlighted) && $rightHighlighted !== '') ? $rightHighlighted : e($rightLine);
+
+    if ($rightType === 'equal') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo ''.$rightRendered."\n";
+        continue;
+    }
+
+    if ($rightType === 'insert') {
+        if ($useTorchlight) {
+            @endphp
+            @once
+                @include('filament.partials.torchlight-dark-overrides')
+                
+            @endonce
+            @php
+        }
+
+        echo '+ '.$rightRendered."\n";
+        continue;
+    }
+
+    echo "\n";
+}
+@endphp
+
+
+
+ +
+
Before
+ @php + $highlightedBeforeFullscreen = $useTorchlight ? $highlight($policyType, (string) $fromText) : null; + @endphp + + @if (is_string($highlightedBeforeFullscreen) && $highlightedBeforeFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedBeforeFullscreen !!}
+ @else +
{{ (string) $fromText }}
+ @endif +
+ +
+
After
+ @php + $highlightedAfterFullscreen = $useTorchlight ? $highlight($policyType, (string) $toText) : null; + @endphp + + @if (is_string($highlightedAfterFullscreen) && $highlightedAfterFullscreen !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedAfterFullscreen !!}
+ @else +
{{ (string) $toText }}
+ @endif +
+
+
+
+
- @else -
{{ $fromText }}
- @endif -
-
- To - @if ($isExpandable($to)) -
- - View - -
{{ $toText }}
-
- @else -
{{ $toText }}
- @endif -
+
+ @else +
+ From + @if ($isExpandable($from)) +
+ + View + +
{{ $fromText }}
+
+ @else +
{{ $fromText }}
+ @endif +
+
+ To + @if ($isExpandable($to)) +
+ + View + +
{{ $toText }}
+
+ @else +
{{ $toText }}
+ @endif +
+ @endif
@else @php @@ -149,7 +754,20 @@ View -
{{ $text }}
+ @php + $isScriptContent = $canHighlightScripts($policyType) && $isScriptKey($name); + $highlighted = $isScriptContent ? $highlight($policyType, (string) $text) : null; + @endphp + + @if (is_string($highlighted) && $highlighted !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlighted !!}
+ @else +
{{ $text }}
+ @endif @else
{{ $text }}
diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index 9fb9398..aabb323 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -7,6 +7,7 @@ $warnings = $state['warnings'] ?? []; $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + $policyType = $state['policy_type'] ?? null; @endphp
@@ -65,7 +66,11 @@ {{-- Settings Blocks (for OMA Settings, Key/Value pairs, etc.) --}} @foreach($settings as $block) - @if($block['type'] === 'table') + @php + $blockType = is_array($block) ? ($block['type'] ?? null) : null; + @endphp + + @if($blockType === 'table') @else + @php + $value = $row['value'] ?? 'N/A'; + + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + @endphp - {{ Str::limit($row['value'] ?? 'N/A', 200) }} + {{ Str::limit((string) $value, 200) }} @endif @@ -105,7 +117,7 @@
- @elseif($block['type'] === 'keyValue') + @elseif($blockType === 'keyValue')
- - {{ Str::limit($entry['value'] ?? 'N/A', 200) }} - + @php + $value = $entry['value'] ?? 'N/A'; + + $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) + && (bool) config('tenantpilot.display.show_script_content', false); + + if (is_array($value) || is_object($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + @endphp + @if($isScriptContent) + @php + $code = (string) $value; + $firstLine = strtok($code, "\n") ?: ''; + + $grammar = 'powershell'; + + if ($policyType === 'deviceShellScript') { + $shebang = trim($firstLine); + + if (str_starts_with($shebang, '#!')) { + if (str_contains($shebang, 'zsh')) { + $grammar = 'zsh'; + } elseif (str_contains($shebang, 'bash')) { + $grammar = 'bash'; + } else { + $grammar = 'sh'; + } + } else { + $grammar = 'sh'; + } + } elseif ($policyType === 'deviceManagementScript' || $policyType === 'deviceHealthScript') { + $grammar = 'powershell'; + } + + $highlightedHtml = null; + + if (class_exists(\Torchlight\Engine\Engine::class)) { + try { + $highlightedHtml = (new \Torchlight\Engine\Engine())->codeToHtml( + code: $code, + grammar: $grammar, + theme: [ + 'light' => 'github-light', + 'dark' => 'github-dark', + ], + withGutter: false, + withWrapper: true, + ); + } catch (\Throwable $e) { + $highlightedHtml = null; + } + } + @endphp + +
+
+ + Show + Hide + + + + {{ number_format(Str::length($code)) }} chars + +
+ +
+ @if (is_string($highlightedHtml) && $highlightedHtml !== '') + @once + @include('filament.partials.torchlight-dark-overrides') + @endonce + +
{!! $highlightedHtml !!}
+ @else +
{{ $code }}
+ @endif +
+
+ @else + + {{ Str::limit((string) $value, 200) }} + + @endif
@endforeach diff --git a/resources/views/filament/partials/torchlight-dark-overrides.blade.php b/resources/views/filament/partials/torchlight-dark-overrides.blade.php new file mode 100644 index 0000000..e93868a --- /dev/null +++ b/resources/views/filament/partials/torchlight-dark-overrides.blade.php @@ -0,0 +1,13 @@ + diff --git a/specs/013-scripts-management/checklists/requirements.md b/specs/013-scripts-management/checklists/requirements.md new file mode 100644 index 0000000..89849c9 --- /dev/null +++ b/specs/013-scripts-management/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Scripts Management + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Assumptions: Supported script policy types are already discoverable in the product, and restore/assignments follow existing system patterns. diff --git a/specs/013-scripts-management/plan.md b/specs/013-scripts-management/plan.md new file mode 100644 index 0000000..57970df --- /dev/null +++ b/specs/013-scripts-management/plan.md @@ -0,0 +1,42 @@ +# Plan: Scripts Management (013) + +**Branch**: `013-scripts-management` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for script policies (PowerShell scripts, macOS shell scripts, and proactive remediations) with readable normalized settings and safe restore behavior including assignments. + +## Scope + +### In scope +- Script policy types: + - `deviceManagementScript` + - `deviceShellScript` + - `deviceHealthScript` +- Readable “Normalized settings” output for the above types. +- Restore apply safety is preserved (type mismatch fails; preview vs execute follows existing system behavior). +- Assignment restore is supported (using existing assignment restore mechanisms and contract metadata). + +### Out of scope +- Adding new UI flows or pages. +- Introducing new external services or background infrastructure. +- Changing how authentication/authorization works. + +## Approach +1. Confirm contract entries exist and are correct for the three script policy types (resource, type families, assignment paths/payload keys). +2. Add a policy normalizer that supports the three script policy types and outputs a stable, readable structure. +3. Register the normalizer in the application normalizer tag. +4. Add tests: + - Normalized output shape/stability for each type. + - Filament “Normalized settings” tab renders without errors for a version of each type. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Scripts may contain large content blobs: normalized view must be readable and avoid overwhelming output (truncate or summarize where needed). +- Platform-specific fields vary: normalizer must handle missing keys safely and remain stable. + +## Success Criteria +- Normalized settings views are readable and stable for all three script policy types. +- Restore execution remains safe and assignment behavior is unchanged/regression-free. +- Tests cover the new normalizer behavior and basic UI render. diff --git a/specs/013-scripts-management/spec.md b/specs/013-scripts-management/spec.md new file mode 100644 index 0000000..b8446df --- /dev/null +++ b/specs/013-scripts-management/spec.md @@ -0,0 +1,112 @@ +# Feature Specification: Scripts Management + +**Feature Branch**: `013-scripts-management` +**Created**: 2026-01-01 +**Status**: Draft +**Input**: User description: "Add end-to-end support for management scripts (Windows PowerShell scripts, macOS shell scripts, and proactive remediations) including readable normalized settings, backup snapshots, and safe restore with assignments." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Restore a script safely (Priority: P1) + +As an admin, I want to restore a script policy from a saved snapshot so I can recover from accidental or unwanted changes. + +**Why this priority**: Restoring known-good configuration is the core safety value of the product. + +**Independent Test**: Can be fully tested by restoring one script policy into a tenant where the script is missing or changed, and verifying the script and its assignments match the snapshot. + +**Acceptance Scenarios**: + +1. **Given** a saved script snapshot and a target tenant where the script does not exist, **When** I run restore for that item, **Then** the system creates a new script policy from the snapshot and reports success. +2. **Given** a saved script snapshot and a target tenant where the script exists with differences, **When** I run restore for that item, **Then** the system updates the existing script policy to match the snapshot and reports success. +3. **Given** a saved script snapshot with assignments, **When** I run restore, **Then** the system applies the assignments using the snapshot data and reports assignment outcomes. + +--- + +### User Story 2 - Readable script configuration (Priority: P2) + +As an admin, I want to view a readable, normalized representation of a script policy so I can understand what it does and compare versions reliably. + +**Why this priority**: If admins cannot quickly understand changes, version history and restore become risky and slow. + +**Independent Test**: Can be tested by opening a script policy version page and confirming that normalized settings display key fields consistently across versions. + +**Acceptance Scenarios**: + +1. **Given** a script policy version, **When** I open the policy version details, **Then** I see a normalized settings view that is stable (same input yields same output ordering/shape). +2. **Given** two versions of the same script policy with changes, **When** I view their normalized settings, **Then** the differences are visible without reading raw JSON. + +--- + +### User Story 3 - Reliable backup capture (Priority: P3) + +As an admin, I want backups/version snapshots of script policies to be captured reliably so I can restore later with confidence. + +**Why this priority**: Restore is only as good as the snapshot quality. + +**Independent Test**: Can be tested by capturing a snapshot of each script policy type and validating it contains the expected configuration fields for that policy. + +**Acceptance Scenarios**: + +1. **Given** an existing script policy, **When** I capture a snapshot/backup, **Then** the saved snapshot contains the complete configuration needed to restore the script policy. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Restoring a snapshot whose policy type does not match the target item (type mismatch) must fail clearly without making changes. +- Restoring when the snapshot contains fields that are not accepted by the target environment must result in a clear failure reason and no partial silent data loss. +- Assignments referencing groups or foundations that cannot be mapped must be reported as manual-required for those assignments. +- Script policies with very large or complex configuration should still render a readable normalized settings view (with safe truncation if needed). + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST support listing and viewing script policies for the supported script policy types. +- **FR-002**: System MUST allow capturing a snapshot of a script policy that is sufficient to restore the policy later. +- **FR-003**: System MUST allow restoring a script policy from a snapshot in a safe manner (create when missing; update when present). +- **FR-004**: System MUST support restoring assignments for script policies using the assignments saved with the snapshot. +- **FR-005**: System MUST present a readable normalized settings view for script policies and script policy versions. +- **FR-006**: System MUST prevent execution of restore if the snapshot policy type does not match the restore item type. +- **FR-007**: System MUST record an audit trail for restore preview and restore execution attempts. + +### Key Entities *(include if feature involves data)* + +- **Script Policy**: A configuration object representing a management script (platform-specific variants), identified by a stable external identifier and a display name. +- **Script Policy Snapshot**: An immutable capture of a script policy’s configuration at a point in time, used for diffing and restore. +- **Script Assignment**: A target association that applies a script policy to a defined scope (e.g., groups/filters), stored with the snapshot and restored with mapping when needed. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: An admin can complete a restore preview for a single script policy in under 1 minute. +- **SC-002**: In a test tenant, restoring a script policy results in the target script policy and assignments matching the snapshot for 100% of supported script policy types. +- **SC-003**: Normalized settings for a script policy are readable and stable: repeated views of the same snapshot produce identical normalized output. +- **SC-004**: Restore failures provide a clear reason (actionable message) in 100% of failure cases. diff --git a/specs/013-scripts-management/tasks.md b/specs/013-scripts-management/tasks.md new file mode 100644 index 0000000..92d99ee --- /dev/null +++ b/specs/013-scripts-management/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Scripts Management (013) + +**Branch**: `013-scripts-management` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for `deviceManagementScript`, `deviceShellScript`, `deviceHealthScript` (resource, type_family, assignment payload key). + +## Phase 2: UI Normalization +- [x] T002 Add a `ScriptsPolicyNormalizer` (or equivalent) to produce readable normalized settings for the three script policy types. +- [x] T003 Register the normalizer in `AppServiceProvider`. + +## Phase 3: Tests + Verification +- [x] T004 Add tests for normalized output (shape + stability) for each script policy type. +- [x] T005 Add Filament render tests for “Normalized settings” tab for each script policy type. +- [x] T006 Run targeted tests. +- [x] T007 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 4: Script Content Display (Safe) +- [x] T008 Add opt-in display + base64 decoding for `scriptContent` in normalized settings. +- [x] T009 Highlight script content with Torch (shebang-based shell + PowerShell default). +- [x] T010 Hide script content behind a Show/Hide button (collapsed by default). +- [x] T011 Highlight script content in Normalized Diff view (From/To). +- [x] T012 Enable Torchlight highlighting in Diff + Before/After views. +- [x] T013 Add “Fullscreen” overlay for script diffs (scroll sync). + +## Open TODOs (Follow-up) +- None yet. diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php new file mode 100644 index 0000000..9db3154 --- /dev/null +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -0,0 +1,128 @@ +actingAs(User::factory()->create()); + + $tenant = Tenant::factory()->create(); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'platform' => 'all', + 'display_name' => 'Script policy', + 'external_id' => 'policy-1', + ]); + + config([ + 'tenantpilot.display.show_script_content' => true, + ]); + + $scriptContent = str_repeat('X', 20); + if ($policyType === 'deviceShellScript') { + $scriptContent = "#!/bin/zsh\n".str_repeat('X', 20); + } + + $version = PolicyVersion::factory()->create([ + 'policy_id' => $policy->id, + 'tenant_id' => $tenant->id, + 'policy_type' => $policyType, + 'snapshot' => [ + '@odata.type' => $odataType, + 'displayName' => 'Script policy', + 'description' => 'desc', + 'scriptContent' => $scriptContent, + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) + ->assertSuccessful(); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings') + ->assertSuccessful(); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +})->with([ + ['deviceManagementScript', '#microsoft.graph.deviceManagementScript'], + ['deviceShellScript', '#microsoft.graph.deviceShellScript'], + ['deviceHealthScript', '#microsoft.graph.deviceHealthScript'], +]); + +it('renders diff tab with highlighted script content for script policies', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $this->actingAs(User::factory()->create()); + + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $tenant = Tenant::factory()->create(); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = \App\Models\Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + ]); + + $scriptOne = "# test\n".str_repeat("Write-Host 'one'\n", 40); + $scriptTwo = "# test\n".str_repeat("Write-Host 'two'\n", 40); + + $v1 = \App\Models\PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 1, + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My script', + 'scriptContent' => base64_encode($scriptOne), + ], + ]); + + $v2 = \App\Models\PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 2, + 'policy_type' => 'deviceManagementScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My script', + 'scriptContent' => base64_encode($scriptTwo), + ], + ]); + + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); + + $this->get($url.'?tab=diff') + ->assertSuccessful() + ->assertSeeText('Fullscreen') + ->assertSeeText("- Write-Host 'one'") + ->assertSeeText("+ Write-Host 'two'") + ->assertSee('bg-danger-50', false) + ->assertSee('bg-success-50', false); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index ce174ee..b634752 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -75,15 +75,11 @@ expect($result)->toBe($assignments); }); -test('fallback on empty response', function () { +test('does not use fallback when primary succeeds with empty assignments', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; $policyType = 'settingsCatalogPolicy'; - $assignments = [ - ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], - ]; - // Primary returns empty $primaryResponse = new GraphResponse( success: true, data: ['value' => []] @@ -97,7 +93,34 @@ ]) ->andReturn($primaryResponse); - // Fallback returns assignments + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); + + expect($result)->toBe([]); +}); + +test('uses fallback when primary endpoint fails', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $policyType = 'settingsCatalogPolicy'; + $assignments = [ + ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], + ]; + + $primaryFailure = new GraphResponse( + success: false, + data: [], + status: 400, + errors: [['message' => 'Bad Request']] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($primaryFailure); + $fallbackResponse = new GraphResponse( success: true, data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]] @@ -152,18 +175,6 @@ ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) ->andReturn($primaryResponse); - // Fallback returns empty - $fallbackResponse = new GraphResponse( - success: true, - data: ['value' => []] - ); - - $this->graphClient - ->shouldReceive('request') - ->once() - ->with('GET', 'deviceManagement/configurationPolicies', Mockery::any()) - ->andReturn($fallbackResponse); - $result = $this->fetcher->fetch($policyType, $tenantId, $policyId); expect($result)->toBe([]); @@ -174,9 +185,8 @@ $policyId = 'policy-456'; $policyType = 'settingsCatalogPolicy'; - // Primary returns empty $primaryResponse = new GraphResponse( - success: true, + success: false, data: ['value' => []] ); diff --git a/tests/Unit/ScriptsPolicyNormalizerTest.php b/tests/Unit/ScriptsPolicyNormalizerTest.php new file mode 100644 index 0000000..61a63a7 --- /dev/null +++ b/tests/Unit/ScriptsPolicyNormalizerTest.php @@ -0,0 +1,168 @@ + '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'description' => 'Does a thing', + 'scriptContent' => str_repeat('A', 10), + 'runFrequency' => 'weekly', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); + expect(collect($result['settings'][0]['entries'])->pluck('key')->all())->toContain('Display name'); +}); + +it('normalizes deviceShellScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContent' => str_repeat('B', 5), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); +}); + +it('normalizes deviceHealthScript into readable settings', function () { + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => str_repeat('C', 3), + 'remediationScriptContent' => str_repeat('D', 4), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + expect($result['settings'][0]['type'])->toBe('keyValue'); +}); + +it('summarizes script content by default', function () { + config([ + 'tenantpilot.display.show_script_content' => false, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => 'ABC', + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('[content: 3 chars]'); +}); + +it('shows script content when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 100, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => "line1\nline2", + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe("line1\nline2"); +}); + +it('decodes scriptContentBase64 when enabled and scriptContent is missing', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 50, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceShellScript', + 'displayName' => 'My macOS shell script', + 'scriptContentBase64' => base64_encode('echo hello'), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceShellScript', 'macOS'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe('echo hello'); +}); + +it('decodes base64-looking scriptContent when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $plain = "# hello\nWrite-Host \"hi\""; + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementScript', + 'displayName' => 'My PS script', + 'scriptContent' => base64_encode($plain), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceManagementScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'scriptContent')['value'])->toBe($plain); +}); + +it('decodes base64-looking detection/remediation script content when enabled', function () { + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $normalizer = app(PolicyNormalizer::class); + + $detection = "# detection\nWrite-Host \"detect\""; + $remediation = "# remediation\nWrite-Host \"fix\""; + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceHealthScript', + 'displayName' => 'My remediation', + 'detectionScriptContent' => base64_encode($detection), + 'remediationScriptContent' => base64_encode($remediation), + ]; + + $result = $normalizer->normalize($snapshot, 'deviceHealthScript', 'windows'); + + $entries = collect($result['settings'][0]['entries']); + + expect($entries->firstWhere('key', 'detectionScriptContent')['value'])->toBe($detection); + expect($entries->firstWhere('key', 'remediationScriptContent')['value'])->toBe($remediation); +}); From 6a1809fbe903262099853eed1a8714c536b406ae Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 2 Jan 2026 11:59:21 +0000 Subject: [PATCH 04/18] 014-enrollment-autopilot (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR completes Feature 014 (Enrollment & Autopilot). Adds normalization for: Autopilot deployment profiles (windowsAutopilotDeploymentProfile) Enrollment Status Page / ESP (windowsEnrollmentStatusPage) Enrollment Restrictions (enrollmentRestriction, restore remains preview-only) Improves settings readability: Autopilot OOBE settings are expanded into readable key/value entries Enrollment restriction platform restrictions are shown as explicit fields (with sensible defaults) Array/list values render as badges (avoids Blade rendering crashes on non-string values) Fixes enrollment configuration type collisions during sync: Canonical type resolution prevents enrollmentRestriction from “claiming” ESP items Safe reclassification updates existing wrong rows instead of skipping Enhances reclassification command: Can detect ESP even if a policy has no local versions (fetches snapshot from Graph) Dry-run by default; apply with --write Tests Added/updated unit + Filament feature tests for normalization and UI rendering. Preview-only enforcement for enrollment restrictions is covered. Targeted test suite and Pint are green. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/20 --- .../ReclassifyEnrollmentConfigurations.php | 162 +++++++++ app/Providers/AppServiceProvider.php | 2 + .../EnrollmentAutopilotPolicyNormalizer.php | 331 ++++++++++++++++++ app/Services/Intune/PolicySyncService.php | 121 ++++++- config/graph_contracts.php | 5 +- config/tenantpilot.php | 20 +- .../policy-settings-standard.blade.php | 87 ++++- .../checklists/requirements.md | 34 ++ specs/014-enrollment-autopilot/plan.md | 48 +++ specs/014-enrollment-autopilot/spec.md | 111 ++++++ specs/014-enrollment-autopilot/tasks.md | 33 ++ ...EnrollmentAutopilotSettingsDisplayTest.php | 151 ++++++++ .../EnrollmentRestrictionsPreviewOnlyTest.php | 111 ++++++ ...olicySettingsStandardRendersArraysTest.php | 63 ++++ ...rollmentConfigurationTypeCollisionTest.php | 73 ++++ ...ifyEnrollmentConfigurationsCommandTest.php | 106 ++++++ tests/Unit/PolicyNormalizerTest.php | 92 +++++ 17 files changed, 1514 insertions(+), 36 deletions(-) create mode 100644 app/Console/Commands/ReclassifyEnrollmentConfigurations.php create mode 100644 app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php create mode 100644 specs/014-enrollment-autopilot/checklists/requirements.md create mode 100644 specs/014-enrollment-autopilot/plan.md create mode 100644 specs/014-enrollment-autopilot/spec.md create mode 100644 specs/014-enrollment-autopilot/tasks.md create mode 100644 tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php create mode 100644 tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php create mode 100644 tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php create mode 100644 tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php create mode 100644 tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php diff --git a/app/Console/Commands/ReclassifyEnrollmentConfigurations.php b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php new file mode 100644 index 0000000..ce0c783 --- /dev/null +++ b/app/Console/Commands/ReclassifyEnrollmentConfigurations.php @@ -0,0 +1,162 @@ +resolveTenantOrNull(); + $dryRun = ! (bool) $this->option('write'); + + $query = Policy::query() + ->with(['tenant']) + ->active() + ->where('policy_type', 'enrollmentRestriction'); + + if ($tenant) { + $query->where('tenant_id', $tenant->id); + } + + $candidates = $query->get(); + + $changedVersions = 0; + $changedPolicies = 0; + $ignoredPolicies = 0; + + foreach ($candidates as $policy) { + $latestVersion = $policy->versions()->latest('version_number')->first(); + $snapshot = $latestVersion?->snapshot; + + if (! is_array($snapshot)) { + $snapshot = $this->fetchSnapshotOrNull($policy); + } + + if (! is_array($snapshot)) { + continue; + } + + if (! $this->isEspSnapshot($snapshot)) { + continue; + } + + $this->line(sprintf( + 'ESP detected: policy=%s tenant_id=%s external_id=%s', + (string) $policy->getKey(), + (string) $policy->tenant_id, + (string) $policy->external_id, + )); + + if ($dryRun) { + continue; + } + + $existingTarget = Policy::query() + ->where('tenant_id', $policy->tenant_id) + ->where('external_id', $policy->external_id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->first(); + + if ($existingTarget) { + $policy->forceFill(['ignored_at' => now()])->save(); + $ignoredPolicies++; + + continue; + } + + $policy->forceFill([ + 'policy_type' => 'windowsEnrollmentStatusPage', + ])->save(); + $changedPolicies++; + + $changedVersions += PolicyVersion::query() + ->where('policy_id', $policy->id) + ->where('policy_type', 'enrollmentRestriction') + ->update(['policy_type' => 'windowsEnrollmentStatusPage']); + } + + $this->info('Done.'); + $this->info('PolicyVersions changed: '.$changedVersions); + $this->info('Policies changed: '.$changedPolicies); + $this->info('Policies ignored: '.$ignoredPolicies); + $this->info('Mode: '.($dryRun ? 'dry-run' : 'write')); + + return Command::SUCCESS; + } + + private function isEspSnapshot(array $snapshot): bool + { + $odataType = $snapshot['@odata.type'] ?? null; + $configurationType = $snapshot['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) + || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + } + + private function fetchSnapshotOrNull(Policy $policy): ?array + { + $tenant = $policy->tenant; + + if (! $tenant) { + return null; + } + + $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; + + $response = $this->graphClient->getPolicy('enrollmentRestriction', $policy->external_id, [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + 'platform' => $policy->platform, + ]); + + if ($response->failed()) { + return null; + } + + $payload = $response->data['payload'] ?? null; + + return is_array($payload) ? $payload : null; + } + + private function resolveTenantOrNull(): ?Tenant + { + $tenantOption = $this->option('tenant'); + + if (! $tenantOption) { + return null; + } + + return Tenant::query() + ->forTenant($tenantOption) + ->firstOrFail(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f6421d8..10e2a8e 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,7 @@ use App\Services\Intune\AppProtectionPolicyNormalizer; use App\Services\Intune\CompliancePolicyNormalizer; use App\Services\Intune\DeviceConfigurationPolicyNormalizer; +use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; @@ -42,6 +43,7 @@ public function register(): void AppProtectionPolicyNormalizer::class, CompliancePolicyNormalizer::class, DeviceConfigurationPolicyNormalizer::class, + EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php new file mode 100644 index 0000000..9177392 --- /dev/null +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -0,0 +1,331 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + + $displayName = Arr::get($snapshot, 'displayName') ?? Arr::get($snapshot, 'name'); + $description = Arr::get($snapshot, 'description'); + + $warnings = []; + + if ($policyType === 'enrollmentRestriction') { + $warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; + } + + $generalEntries = [ + ['key' => 'Type', 'value' => $policyType], + ]; + + if (is_string($displayName) && $displayName !== '') { + $generalEntries[] = ['key' => 'Display name', 'value' => $displayName]; + } + + if (is_string($description) && $description !== '') { + $generalEntries[] = ['key' => 'Description', 'value' => $description]; + } + + $odataType = Arr::get($snapshot, '@odata.type'); + if (is_string($odataType) && $odataType !== '') { + $generalEntries[] = ['key' => '@odata.type', 'value' => $odataType]; + } + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $generalEntries[] = ['key' => 'Scope tag IDs', 'value' => array_values($roleScopeTagIds)]; + } + + $settings = [ + [ + 'type' => 'keyValue', + 'title' => 'General', + 'entries' => $generalEntries, + ], + ]; + + $typeBlock = match ($policyType) { + 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), + 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), + 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), + default => null, + }; + + if ($typeBlock !== null) { + $settings[] = $typeBlock; + } + + $settings = array_values(array_filter($settings)); + + return [ + 'status' => 'ok', + 'settings' => $settings, + 'warnings' => $warnings, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildAutopilotBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'deviceNameTemplate' => 'Device name template', + 'language' => 'Language', + 'locale' => 'Locale', + 'deploymentMode' => 'Deployment mode', + 'deviceType' => 'Device type', + 'enableWhiteGlove' => 'Pre-provisioning (White Glove)', + 'hybridAzureADJoinSkipConnectivityCheck' => 'Skip Hybrid AAD connectivity check', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $oobe = Arr::get($snapshot, 'outOfBoxExperienceSettings'); + if (is_array($oobe) && $oobe !== []) { + $oobe = Arr::except($oobe, ['@odata.type']); + + foreach ($this->expandOutOfBoxExperienceEntries($oobe) as $entry) { + $entries[] = $entry; + } + } + + $assignments = Arr::get($snapshot, 'assignments'); + if (is_array($assignments) && $assignments !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Autopilot profile', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + private function expandOutOfBoxExperienceEntries(array $oobe): array + { + $knownKeys = [ + 'hideEULA' => 'Hide EULA', + 'userType' => 'User type', + 'hideEscapeLink' => 'Hide escape link', + 'deviceUsageType' => 'Device usage type', + 'hidePrivacySettings' => 'Hide privacy settings', + 'skipKeyboardSelectionPage' => 'Skip keyboard selection page', + 'skipExpressSettings' => 'Skip express settings', + ]; + + $entries = []; + + foreach ($knownKeys as $key => $label) { + if (! array_key_exists($key, $oobe)) { + continue; + } + + $value = $oobe[$key]; + + if (is_bool($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled']; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_int($value) || is_float($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } + + unset($oobe[$key]); + } + + foreach ($oobe as $key => $value) { + $label = Str::headline((string) $key); + + if (is_bool($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value ? 'Enabled' : 'Disabled']; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_int($value) || is_float($value)) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } elseif (is_array($value) && $value !== []) { + $entries[] = ['key' => "OOBE: {$label}", 'value' => $value]; + } + } + + return $entries; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentStatusPageBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'showInstallationProgress' => 'Show installation progress', + 'blockDeviceSetupRetryByUser' => 'Block retry by user', + 'allowDeviceResetOnInstallFailure' => 'Allow device reset on install failure', + 'installProgressTimeoutInMinutes' => 'Install progress timeout (minutes)', + 'allowLogCollectionOnInstallFailure' => 'Allow log collection on failure', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $selected = Arr::get($snapshot, 'selectedMobileAppIds'); + if (is_array($selected) && $selected !== []) { + $entries[] = ['key' => 'Selected mobile app IDs', 'value' => array_values($selected)]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment Status Page (ESP)', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentRestrictionBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $platformRestrictions = Arr::get($snapshot, 'platformRestrictions'); + $platformRestriction = Arr::get($snapshot, 'platformRestriction'); + + $platformPayload = is_array($platformRestrictions) && $platformRestrictions !== [] + ? $platformRestrictions + : (is_array($platformRestriction) ? $platformRestriction : null); + + if (is_array($platformPayload) && $platformPayload !== []) { + $platformPayload = Arr::except($platformPayload, ['@odata.type']); + + $platformBlocked = Arr::get($platformPayload, 'platformBlocked'); + if (is_bool($platformBlocked)) { + $entries[] = ['key' => 'Platform blocked', 'value' => $platformBlocked ? 'Enabled' : 'Disabled']; + } + + $personalBlocked = Arr::get($platformPayload, 'personalDeviceEnrollmentBlocked'); + if (is_bool($personalBlocked)) { + $entries[] = ['key' => 'Personal device enrollment blocked', 'value' => $personalBlocked ? 'Enabled' : 'Disabled']; + } + + $osMin = Arr::get($platformPayload, 'osMinimumVersion'); + $entries[] = [ + 'key' => 'OS minimum version', + 'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None', + ]; + + $osMax = Arr::get($platformPayload, 'osMaximumVersion'); + $entries[] = [ + 'key' => 'OS maximum version', + 'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None', + ]; + + $blockedManufacturers = Arr::get($platformPayload, 'blockedManufacturers'); + $entries[] = [ + 'key' => 'Blocked manufacturers', + 'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== []) + ? array_values($blockedManufacturers) + : ['None'], + ]; + + $blockedSkus = Arr::get($platformPayload, 'blockedSkus'); + $entries[] = [ + 'key' => 'Blocked SKUs', + 'value' => (is_array($blockedSkus) && $blockedSkus !== []) + ? array_values($blockedSkus) + : ['None'], + ]; + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment restrictions', + 'entries' => $entries, + ]; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } +} diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 3d4fc06..cf08815 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -3,6 +3,7 @@ namespace App\Services\Intune; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphErrorMapper; @@ -78,6 +79,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr continue; } + $canonicalPolicyType = $this->resolveCanonicalPolicyType($policyType, $policyData); + + if ($canonicalPolicyType !== $policyType) { + continue; + } + if ($policyType === 'appProtectionPolicy') { $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; @@ -96,15 +103,11 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr $displayName = $policyData['displayName'] ?? $policyData['name'] ?? 'Unnamed policy'; $policyPlatform = $platform ?? ($policyData['platform'] ?? null); - $existingWithDifferentType = Policy::query() - ->where('tenant_id', $tenant->id) - ->where('external_id', $externalId) - ->where('policy_type', '!=', $policyType) - ->exists(); - - if ($existingWithDifferentType) { - continue; - } + $this->reclassifyEnrollmentConfigurationPoliciesIfNeeded( + tenantId: $tenant->id, + externalId: $externalId, + policyType: $policyType, + ); $policy = Policy::updateOrCreate( [ @@ -128,6 +131,106 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr return $synced; } + private function resolveCanonicalPolicyType(string $policyType, array $policyData): string + { + if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + return $policyType; + } + + if ($this->isEnrollmentStatusPageItem($policyData)) { + return 'windowsEnrollmentStatusPage'; + } + + if ($this->isEnrollmentRestrictionItem($policyData)) { + return 'enrollmentRestriction'; + } + + return $policyType; + } + + private function isEnrollmentStatusPageItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration') === 0) + || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); + } + + private function isEnrollmentRestrictionItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + $restrictionOdataTypes = [ + '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ]; + + if (is_string($odataType)) { + foreach ($restrictionOdataTypes as $expected) { + if (strcasecmp($odataType, $expected) === 0) { + return true; + } + } + } + + return is_string($configurationType) + && in_array($configurationType, [ + 'deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentLimitConfiguration', + ], true); + } + + private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + return; + } + + $enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage']; + + $existingCorrect = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->first(); + + if ($existingCorrect) { + Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $enrollmentTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + return; + } + + $existingWrong = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $enrollmentTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->first(); + + if (! $existingWrong) { + return; + } + + $existingWrong->forceFill([ + 'policy_type' => $policyType, + ])->save(); + + PolicyVersion::query() + ->where('policy_id', $existingWrong->id) + ->update(['policy_type' => $policyType]); + } + /** * Re-fetch a single policy from Graph and update local metadata. */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 2b877e9..eb9bd59 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -340,8 +340,9 @@ 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_expand' => [], 'type_family' => [ - '#microsoft.graph.deviceEnrollmentConfiguration', - '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + '#microsoft.graph.deviceEnrollmentLimitConfiguration', ], 'create_method' => 'POST', 'update_method' => 'PATCH', diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 17efa20..7ec3820 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -124,16 +124,6 @@ 'restore' => 'enabled', 'risk' => 'medium', ], - [ - 'type' => 'enrollmentRestriction', - 'label' => 'Enrollment Restrictions', - 'category' => 'Enrollment', - 'platform' => 'all', - 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'backup' => 'full', - 'restore' => 'preview-only', - 'risk' => 'high', - ], [ 'type' => 'windowsAutopilotDeploymentProfile', 'label' => 'Windows Autopilot Profiles', @@ -155,6 +145,16 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'enrollmentRestriction', + 'label' => 'Enrollment Restrictions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'endpointSecurityIntent', 'label' => 'Endpoint Security Intents', diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index aabb323..f644e9f 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -7,7 +7,58 @@ $warnings = $state['warnings'] ?? []; $settings = $state['settings'] ?? []; $settingsTable = $state['settings_table'] ?? null; + $policyType = $state['policy_type'] ?? null; + + $stringifyValue = function (mixed $value): string { + if (is_null($value)) { + return 'N/A'; + } + + if (is_bool($value)) { + return $value ? 'Enabled' : 'Disabled'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + if (is_array($value)) { + $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; + } + + if (is_object($value)) { + if (method_exists($value, '__toString')) { + return (string) $value; + } + + $encoded = json_encode((array) $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($encoded) ? $encoded : 'N/A'; + } + + return 'N/A'; + }; + + $shouldRenderBadges = function (mixed $value): bool { + if (! is_array($value) || $value === []) { + return false; + } + + if (! array_is_list($value)) { + return false; + } + + foreach ($value as $item) { + if (! is_scalar($item) && ! is_null($item)) { + return false; + } + } + + return true; + }; @endphp
@@ -99,16 +150,17 @@ {{ $row['value'] }} + @elseif($shouldRenderBadges($row['value'] ?? null)) +
+ @foreach(($row['value'] ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
@else - @php - $value = $row['value'] ?? 'N/A'; - - if (is_array($value) || is_object($value)) { - $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } - @endphp - {{ Str::limit((string) $value, 200) }} + {{ Str::limit($stringifyValue($row['value'] ?? null), 200) }} @endif @@ -136,18 +188,15 @@
@php - $value = $entry['value'] ?? 'N/A'; + $rawValue = $entry['value'] ?? null; $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) && (bool) config('tenantpilot.display.show_script_content', false); - - if (is_array($value) || is_object($value)) { - $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } @endphp + @if($isScriptContent) @php - $code = (string) $value; + $code = is_string($rawValue) ? $rawValue : $stringifyValue($rawValue); $firstLine = strtok($code, "\n") ?: ''; $grammar = 'powershell'; @@ -219,9 +268,17 @@ @endif
+ @elseif($shouldRenderBadges($rawValue)) +
+ @foreach(($rawValue ?? []) as $item) + + {{ is_bool($item) ? ($item ? 'Enabled' : 'Disabled') : (string) $item }} + + @endforeach +
@else - {{ Str::limit((string) $value, 200) }} + {{ Str::limit($stringifyValue($rawValue), 200) }} @endif diff --git a/specs/014-enrollment-autopilot/checklists/requirements.md b/specs/014-enrollment-autopilot/checklists/requirements.md new file mode 100644 index 0000000..d73ba6c --- /dev/null +++ b/specs/014-enrollment-autopilot/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Enrollment & Autopilot + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Assumptions: Restore behavior for enrollment restrictions remains preview-only until a separate product decision explicitly enables it. diff --git a/specs/014-enrollment-autopilot/plan.md b/specs/014-enrollment-autopilot/plan.md new file mode 100644 index 0000000..a283ad3 --- /dev/null +++ b/specs/014-enrollment-autopilot/plan.md @@ -0,0 +1,48 @@ +# Plan: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` +**Date**: 2026-01-01 +**Input**: [spec.md](./spec.md) + +## Goal +Provide end-to-end support for enrollment & Autopilot configuration items with readable normalized settings and safe restore behavior. + +## Scope + +### In scope +- Policy types: + - `windowsAutopilotDeploymentProfile` (restore enabled) + - `windowsEnrollmentStatusPage` (restore enabled) + - `enrollmentRestriction` (restore preview-only) +- Readable “Normalized settings” for the above types. +- Restore behavior: + - Autopilot/ESP: apply via existing restore mechanisms (create-if-missing allowed) + - Enrollment restrictions: must be skipped on execution by default (preview-only) +- Tests for normalization + UI rendering + preview-only enforcement. + +### Out of scope +- New restore wizard flows/pages. +- Enabling execution for enrollment restrictions (requires product decision). +- New external services. + +## Approach +1. Verify `config/graph_contracts.php` and `config/tenantpilot.php` entries for the three policy types. +2. Implement a new policy type normalizer to provide stable, enrollment-relevant blocks for: + - Autopilot deployment profiles + - Enrollment Status Page + - Enrollment restrictions +3. Register the normalizer with the `policy-type-normalizers` tag. +4. Add tests: + - Unit tests for normalized output stability/shape. + - Filament feature tests verifying “Normalized settings” renders for each type. + - Feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution. +5. Run targeted tests and Pint. + +## Risks & Mitigations +- Payload shape variance across tenants: normalizer must handle missing keys safely. +- Enrollment restrictions are high impact: execution must remain disabled by default (preview-only). + +## Success Criteria +- Normalized settings are stable and readable for all in-scope types. +- Restore execution skips preview-only types and reports clear result reasons. +- Tests cover normalization and preview-only enforcement. diff --git a/specs/014-enrollment-autopilot/spec.md b/specs/014-enrollment-autopilot/spec.md new file mode 100644 index 0000000..edef695 --- /dev/null +++ b/specs/014-enrollment-autopilot/spec.md @@ -0,0 +1,111 @@ +# Feature Specification: Enrollment & Autopilot + +**Feature Branch**: `014-enrollment-autopilot` +**Created**: 2026-01-01 +**Status**: Draft +**Input**: User description: "Improve enrollment and Autopilot configuration safety by adding readable normalized settings, reliable snapshot capture, and safe restore behavior for enrollment restrictions, enrollment status page, and Autopilot deployment profiles." + +## User Scenarios & Testing *(mandatory)* + + + +### User Story 1 - Restore Autopilot/ESP safely (Priority: P1) + +As an admin, I want to restore Autopilot deployment profiles and the Enrollment Status Page configuration from saved snapshots so I can recover enrollment readiness after changes. + +**Why this priority**: Enrollment misconfiguration blocks device onboarding; fast recovery is critical. + +**Independent Test**: Can be tested by restoring one Autopilot profile and one Enrollment Status Page item from snapshots into a target tenant and verifying they match the snapshot. + +**Acceptance Scenarios**: + +1. **Given** a saved Autopilot deployment profile snapshot and a target tenant where the profile is missing, **When** I restore it, **Then** a new profile is created and restore reports success. +2. **Given** a saved Enrollment Status Page snapshot and a target tenant where the item exists with differences, **When** I restore it, **Then** the configuration is updated to match the snapshot and restore reports success. + +--- + +### User Story 2 - Restore behavior is explicit for high-risk enrollment restrictions (Priority: P2) + +As an admin, I want high-risk enrollment restrictions to be handled explicitly (preview-only unless intentionally enabled) so I do not accidentally break enrollment flows. + +**Why this priority**: Enrollment restrictions can lock out device onboarding; accidental changes are high impact. + +**Independent Test**: Can be tested by attempting restore of an enrollment restriction item and verifying the system does not apply changes when it is configured as preview-only. + +**Acceptance Scenarios**: + +1. **Given** an enrollment restriction snapshot and the feature is allowed for preview-only, **When** I run restore execution, **Then** the system skips applying changes and records a result indicating preview-only behavior. + +--- + +### User Story 3 - Readable normalized settings (Priority: P3) + +As an admin, I want to view readable normalized settings for Autopilot and Enrollment configurations so I can understand what will happen during device onboarding. + +**Why this priority**: Enrollment troubleshooting is faster when key settings are visible and consistent. + +**Independent Test**: Can be tested by opening a version details page and confirming a stable normalized settings view is present and readable. + +**Acceptance Scenarios**: + +1. **Given** a saved Autopilot/ESP snapshot, **When** I view the policy version, **Then** I see a normalized settings view that highlights key enrollment-relevant fields. + +--- + +[Add more user stories as needed, each with an assigned priority] + +### Edge Cases + +- Autopilot or ESP configuration in the target tenant is missing: system must create or clearly fail with an actionable reason. +- Restoring Enrollment Status Page items must not silently drop settings; failures must be explicit. +- Enrollment restrictions remain preview-only unless explicitly enabled by product decision; execution must not apply them by default. +- Assignments (if present for these types) that cannot be mapped must be reported as manual-required. + +## Requirements *(mandatory)* + + + +### Functional Requirements + +- **FR-001**: System MUST support listing and viewing enrollment and Autopilot configuration items for the supported types. +- **FR-002**: System MUST capture snapshots for these configuration items that are sufficient for later restore. +- **FR-003**: System MUST support restore for Autopilot deployment profiles and Enrollment Status Page configuration. +- **FR-004**: System MUST treat enrollment restrictions as high risk and default them to preview-only behavior unless explicitly enabled. +- **FR-005**: System MUST present a readable normalized settings view for these configuration items and their versions. +- **FR-006**: System MUST prevent restore execution if the snapshot type does not match the target item type. +- **FR-007**: System MUST record audit entries for restore preview and restore execution attempts. + +### Key Entities *(include if feature involves data)* + +- **Autopilot Deployment Profile**: A configuration object that defines device provisioning behavior during Autopilot. +- **Enrollment Status Page Configuration**: A configuration object that defines the onboarding status experience during enrollment. +- **Enrollment Restriction**: A high-risk configuration object that can block or constrain enrollment. +- **Snapshot**: An immutable capture of a configuration object at a point in time. + +## Success Criteria *(mandatory)* + + + +### Measurable Outcomes + +- **SC-001**: An admin can complete a restore preview for a single Autopilot/ESP item in under 1 minute. +- **SC-002**: In a test tenant, restoring Autopilot deployment profiles and Enrollment Status Page results in configurations matching the snapshot for 100% of supported items. +- **SC-003**: Enrollment restrictions remain non-executable by default (preview-only) with clear status reporting in 100% of attempts. +- **SC-004**: Normalized settings views for these items are stable and readable (same snapshot yields identical normalized output). diff --git a/specs/014-enrollment-autopilot/tasks.md b/specs/014-enrollment-autopilot/tasks.md new file mode 100644 index 0000000..0d507a1 --- /dev/null +++ b/specs/014-enrollment-autopilot/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Enrollment & Autopilot (014) + +**Branch**: `014-enrollment-autopilot` | **Date**: 2026-01-01 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Contracts Review +- [x] T001 Verify `config/graph_contracts.php` entries for: + - `windowsAutopilotDeploymentProfile` + - `windowsEnrollmentStatusPage` + - `enrollmentRestriction` + (resource, type_family, create/update methods, assignment paths/payload keys) +- [x] T002 Verify `config/tenantpilot.php` entries and restore modes: + - Autopilot/ESP = `enabled` + - Enrollment restrictions = `preview-only` + +## Phase 2: UI Normalization +- [x] T003 Add an `EnrollmentAutopilotPolicyNormalizer` (or equivalent) that produces readable normalized settings for the three policy types. +- [x] T004 Register the normalizer in the app container/provider (tag `policy-type-normalizers`). + +## Phase 3: Restore Safety +- [x] T005 Add a feature test verifying `enrollmentRestriction` restore is preview-only and skipped on execution (no Graph apply calls). + +## Phase 3b: Enrollment Configuration Type Collisions +- [x] T005b Fix ESP vs enrollment restriction collision on `deviceEnrollmentConfigurations` sync (canonical type resolution + safe reclassification). + +## Phase 4: Tests + Verification +- [x] T006 Add unit tests for normalized output (shape + stability) for the three policy types. +- [x] T007 Add Filament render tests for “Normalized settings” tab for the three policy types. +- [x] T008 Run targeted tests. +- [x] T009 Run Pint (`./vendor/bin/pint --dirty`). + +## Open TODOs (Follow-up) +- None. diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php new file mode 100644 index 0000000..a35ada5 --- /dev/null +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -0,0 +1,151 @@ + 'local-tenant', + 'name' => 'Tenant One', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->tenant = $tenant; + $this->user = User::factory()->create(); +}); + +test('policy detail renders normalized settings for Autopilot profiles', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'autopilot-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'display_name' => 'Autopilot Profile A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile A', + 'deviceNameTemplate' => 'DEV-%SERIAL%', + 'enableWhiteGlove' => true, + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Autopilot profile'); + $response->assertSee('Device name template'); + $response->assertSee('DEV-%SERIAL%'); + $response->assertSee('Pre-provisioning (White Glove)'); + $response->assertSee('Enabled'); + $response->assertSee('OOBE: Hide EULA'); + $response->assertSee('OOBE: User type'); +}); + +test('policy detail renders normalized settings for Enrollment Status Page (ESP)', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'windowsEnrollmentStatusPage', + 'display_name' => 'ESP A', + 'platform' => 'windows', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration', + 'displayName' => 'ESP A', + 'priority' => 1, + 'showInstallationProgress' => true, + 'installProgressTimeoutInMinutes' => 60, + 'selectedMobileAppIds' => ['app-1', 'app-2'], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Enrollment Status Page (ESP)'); + $response->assertSee('Priority'); + $response->assertSee('1'); + $response->assertSee('Show installation progress'); + $response->assertSee('Enabled'); + $response->assertSee('Selected mobile app IDs'); + $response->assertSee('app-1'); + $response->assertSee('app-2'); +}); + +test('policy detail renders normalized settings for enrollment restrictions', function () { + $policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'enroll-restrict-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'Restriction A', + 'platform' => 'all', + ]); + + PolicyVersion::create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'displayName' => 'Restriction A', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + 'platformRestriction' => [ + 'platformBlocked' => false, + 'personalDeviceEnrollmentBlocked' => true, + 'blockedSkus' => ['sku-1'], + ], + ], + ]); + + $response = $this->actingAs($this->user) + ->get(PolicyResource::getUrl('view', ['record' => $policy])); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Enrollment restrictions'); + $response->assertSee('Personal device enrollment blocked'); + $response->assertSee('Enabled'); + $response->assertSee('Blocked SKUs'); + $response->assertSee('sku-1'); +}); diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php new file mode 100644 index 0000000..051fcb0 --- /dev/null +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -0,0 +1,111 @@ + []]); + } + + 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-enrollment-restriction', + 'name' => 'Tenant Enrollment Restriction', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-restriction-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'Enrollment Restriction', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Restriction 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.deviceEnrollmentConfiguration', + '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) === 'enrollmentRestriction'); + + 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); +}); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php new file mode 100644 index 0000000..566c323 --- /dev/null +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -0,0 +1,63 @@ + 'tenant-arrays', + 'name' => 'Tenant Arrays', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-arrays-1', + 'policy_type' => 'windowsAutopilotDeploymentProfile', + 'display_name' => 'Autopilot Policy With Arrays', + 'platform' => 'windows', + ]); + + 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' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Policy With Arrays', + 'roleScopeTagIds' => ['0', '1'], + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings'); + + $response->assertOk(); + $response->assertSee('Settings'); + $response->assertSee('Scope tag IDs'); + $response->assertSee('0'); + $response->assertSee('1'); + $response->assertSee('OOBE: Hide EULA'); + $response->assertSee('OOBE: User type'); + $response->assertSee('standard'); +}); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php new file mode 100644 index 0000000..a311e43 --- /dev/null +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -0,0 +1,73 @@ + 'tenant-sync-collision', + 'name' => 'Tenant Sync Collision', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + // Simulate an older bug: ESP row was synced under enrollmentRestriction. + $wrong = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified', + 'platform' => 'all', + ]); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $espPayload = [ + 'id' => 'esp-1', + 'displayName' => 'Enrollment Status Page', + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($espPayload) { + if ($policyType === 'enrollmentRestriction') { + // Shared endpoint can return ESP items if unfiltered. + return new GraphResponse(true, [$espPayload]); + } + + if ($policyType === 'windowsEnrollmentStatusPage') { + return new GraphResponse(true, [$espPayload]); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + [ + 'type' => 'enrollmentRestriction', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'windowsEnrollmentStatusPage', + 'platform' => 'all', + 'filter' => null, + ], + ]); + + $wrong->refresh(); + + expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); diff --git a/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php new file mode 100644 index 0000000..5186cdb --- /dev/null +++ b/tests/Feature/ReclassifyEnrollmentConfigurationsCommandTest.php @@ -0,0 +1,106 @@ + 'tenant-reclassify', + 'name' => 'Tenant Reclassify', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified', + 'platform' => 'all', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => 'enrollmentRestriction', + 'platform' => 'all', + 'created_by' => 'tester@example.com', + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + 'displayName' => 'ESP Misclassified', + ], + ]); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id]) + ->assertSuccessful(); + + $version->refresh(); + $policy->refresh(); + + expect($version->policy_type)->toBe('enrollmentRestriction'); + expect($policy->policy_type)->toBe('enrollmentRestriction'); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true]) + ->assertSuccessful(); + + $version->refresh(); + $policy->refresh(); + + expect($version->policy_type)->toBe('windowsEnrollmentStatusPage'); + expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); + +test('reclassify command can detect ESP even when a policy has no versions', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-reclassify-no-versions', + 'name' => 'Tenant Reclassify (No Versions)', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-2', + 'policy_type' => 'enrollmentRestriction', + 'display_name' => 'ESP Misclassified (No Versions)', + 'platform' => 'all', + ]); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $mock->shouldReceive('getPolicy') + ->andReturn(new GraphResponse(true, [ + 'payload' => [ + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + 'displayName' => 'ESP Misclassified (No Versions)', + ], + ])); + }); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id]) + ->assertSuccessful(); + + $policy->refresh(); + expect($policy->policy_type)->toBe('enrollmentRestriction'); + + $this->artisan('intune:reclassify-enrollment-configurations', ['--tenant' => $tenant->tenant_id, '--write' => true]) + ->assertSuccessful(); + + $policy->refresh(); + expect($policy->policy_type)->toBe('windowsEnrollmentStatusPage'); +}); diff --git a/tests/Unit/PolicyNormalizerTest.php b/tests/Unit/PolicyNormalizerTest.php index 3685f2d..9a7dccf 100644 --- a/tests/Unit/PolicyNormalizerTest.php +++ b/tests/Unit/PolicyNormalizerTest.php @@ -66,3 +66,95 @@ expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch'); }); + +it('normalizes enrollment restrictions platform restriction payload', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + 'displayName' => 'DeviceTypeRestriction', + 'version' => 2, + // Graph uses this singular shape for platform restriction configs. + 'platformRestriction' => [ + 'platformBlocked' => false, + 'personalDeviceEnrollmentBlocked' => true, + ], + ]; + + $result = $this->normalizer->normalize($snapshot, 'enrollmentRestriction', 'all'); + + $block = collect($result['settings'])->firstWhere('title', 'Enrollment restrictions'); + expect($block)->not->toBeNull(); + + $platformEntry = collect($block['entries'] ?? [])->firstWhere('key', 'Platform restrictions'); + expect($platformEntry)->toBeNull(); + + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform blocked')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); + + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS minimum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS maximum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked manufacturers')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked SKUs')['value'] ?? null)->toBe(['None']); +}); + +it('normalizes Autopilot deployment profile key fields', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.azureADWindowsAutopilotDeploymentProfile', + 'displayName' => 'Autopilot Profile A', + 'description' => 'Used for standard devices', + 'deviceNameTemplate' => 'DEV-%SERIAL%', + 'deploymentMode' => 'singleUser', + 'deviceType' => 'windowsPc', + 'enableWhiteGlove' => true, + 'outOfBoxExperienceSettings' => [ + 'hideEULA' => true, + 'userType' => 'standard', + ], + ]; + + $result = $this->normalizer->normalize($snapshot, 'windowsAutopilotDeploymentProfile', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['warnings'])->toBe([]); + + $general = collect($result['settings'])->firstWhere('title', 'General'); + expect($general)->not->toBeNull(); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsAutopilotDeploymentProfile'); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('Autopilot Profile A'); + + $block = collect($result['settings'])->firstWhere('title', 'Autopilot profile'); + expect($block)->not->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Device name template')['value'] ?? null)->toBe('DEV-%SERIAL%'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Pre-provisioning (White Glove)')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: Hide EULA')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'OOBE: User type')['value'] ?? null)->toBe('standard'); +}); + +it('normalizes Enrollment Status Page key fields', function () { + $snapshot = [ + '@odata.type' => '#microsoft.graph.windowsEnrollmentStatusPageConfiguration', + 'displayName' => 'ESP A', + 'priority' => 1, + 'showInstallationProgress' => true, + 'blockDeviceSetupRetryByUser' => false, + 'installProgressTimeoutInMinutes' => 60, + 'selectedMobileAppIds' => ['app-1', 'app-2'], + ]; + + $result = $this->normalizer->normalize($snapshot, 'windowsEnrollmentStatusPage', 'windows'); + + expect($result['status'])->toBe('ok'); + expect($result['warnings'])->toBe([]); + + $general = collect($result['settings'])->firstWhere('title', 'General'); + expect($general)->not->toBeNull(); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Type')['value'] ?? null)->toBe('windowsEnrollmentStatusPage'); + expect(collect($general['entries'] ?? [])->firstWhere('key', 'Display name')['value'] ?? null)->toBe('ESP A'); + + $block = collect($result['settings'])->firstWhere('title', 'Enrollment Status Page (ESP)'); + expect($block)->not->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Priority')['value'] ?? null)->toBe(1); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Show installation progress')['value'] ?? null)->toBe('Enabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Block retry by user')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Selected mobile app IDs')['value'] ?? null)->toBe(['app-1', 'app-2']); +}); From 76e10fc40447169736301634119b052cceaeda92 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 2 Jan 2026 13:59:15 +0000 Subject: [PATCH 05/18] 015-policy-picker-ux (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the “Add Policies” picker with a modal table (search, pagination, multi-select). Adds filters: policy type, platform, last synced, ignored, has versions; “Select all” applies to the current filtered results. Improves identifiers shown (short external id), and fixes has-versions filtering behavior. Backup set items table: groups row actions (View policy / Remove) into an action group. Adds bulk action to remove multiple backup items at once. Updates/adds tests covering the picker table bulk add and backup items bulk remove. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/21 --- .../BackupItemsRelationManager.php | 191 ++++++------- app/Livewire/BackupSetPolicyPickerTable.php | 261 ++++++++++++++++++ .../modals/backup-set-policy-picker.blade.php | 3 + .../backup-set-policy-picker-table.blade.php | 20 ++ .../checklists/requirements.md | 30 ++ specs/015-policy-picker-ux/plan.md | 32 +++ specs/015-policy-picker-ux/spec.md | 37 +++ specs/015-policy-picker-ux/tasks.md | 24 ++ tests/Feature/Filament/BackupCreationTest.php | 20 +- .../Filament/BackupItemsBulkRemoveTest.php | 74 +++++ .../BackupSetPolicyPickerTableTest.php | 97 +++++++ tests/Unit/PolicyPickerOptionLabelTest.php | 13 + 12 files changed, 691 insertions(+), 111 deletions(-) create mode 100644 app/Livewire/BackupSetPolicyPickerTable.php create mode 100644 resources/views/filament/modals/backup-set-policy-picker.blade.php create mode 100644 resources/views/livewire/backup-set-policy-picker-table.blade.php create mode 100644 specs/015-policy-picker-ux/checklists/requirements.md create mode 100644 specs/015-policy-picker-ux/plan.md create mode 100644 specs/015-policy-picker-ux/spec.md create mode 100644 specs/015-policy-picker-ux/tasks.md create mode 100644 tests/Feature/Filament/BackupItemsBulkRemoveTest.php create mode 100644 tests/Feature/Filament/BackupSetPolicyPickerTableTest.php create mode 100644 tests/Unit/PolicyPickerOptionLabelTest.php diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index eddf393..914991f 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -4,17 +4,15 @@ use App\Filament\Resources\PolicyResource; use App\Models\BackupItem; -use App\Models\Policy; -use App\Models\Tenant; use App\Services\Intune\AuditLogger; -use App\Services\Intune\BackupService; use Filament\Actions; -use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; class BackupItemsRelationManager extends RelationManager { @@ -99,113 +97,102 @@ public function table(Table $table): Table Actions\Action::make('addPolicies') ->label('Add Policies') ->icon('heroicon-o-plus') - ->form([ - Forms\Components\Select::make('policy_ids') - ->label('Policies') - ->multiple() - ->required() - ->searchable() - ->options(function (RelationManager $livewire) { - $backupSet = $livewire->getOwnerRecord(); - $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); - - $existing = $backupSet - ? $backupSet->items()->pluck('policy_id')->filter()->all() - : []; - - return Policy::query() - ->where('tenant_id', $tenantId) - ->whereNull('ignored_at') - ->where('last_synced_at', '>', now()->subDays(7)) // Hide deleted policies (Feature 005 workaround) - ->when($existing, fn (Builder $query) => $query->whereNotIn('id', $existing)) - ->orderBy('display_name') - ->pluck('display_name', 'id'); - }), - Forms\Components\Checkbox::make('include_assignments') - ->label('Include assignments') - ->default(true) - ->helperText('Captures assignment include/exclude targeting and filters.'), - Forms\Components\Checkbox::make('include_scope_tags') - ->label('Include scope tags') - ->default(true) - ->helperText('Captures policy scope tag IDs.'), - Forms\Components\Checkbox::make('include_foundations') - ->label('Include foundations') - ->default(true) - ->helperText('Captures assignment filters, scope tags, and notification templates.'), - ]) - ->action(function (array $data, BackupService $service) { - if (empty($data['policy_ids'])) { - Notification::make() - ->title('No policies selected') - ->warning() - ->send(); - - return; - } - + ->modalHeading('Add Policies') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (): View { $backupSet = $this->getOwnerRecord(); - $tenant = $backupSet?->tenant ?? Tenant::current(); - $service->addPoliciesToSet( - tenant: $tenant, - backupSet: $backupSet, - policyIds: $data['policy_ids'], - actorEmail: auth()->user()?->email, - actorName: auth()->user()?->name, - includeAssignments: $data['include_assignments'] ?? false, - includeScopeTags: $data['include_scope_tags'] ?? false, - includeFoundations: $data['include_foundations'] ?? false, - ); - - $notificationTitle = ($data['include_foundations'] ?? false) - ? 'Backup items added' - : 'Policies added to backup'; - - Notification::make() - ->title($notificationTitle) - ->success() - ->send(); + return view('filament.modals.backup-set-policy-picker', [ + 'backupSetId' => $backupSet->getKey(), + ]); }), ]) ->actions([ - Actions\ViewAction::make() - ->label('View policy') - ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) - ->hidden(fn ($record) => ! $record->policy_id) - ->openUrlInNewTab(true), - Actions\Action::make('remove') - ->label('Remove') - ->color('danger') - ->icon('heroicon-o-x-mark') - ->requiresConfirmation() - ->action(function (BackupItem $record, AuditLogger $auditLogger) { - $record->delete(); + Actions\ActionGroup::make([ + Actions\ViewAction::make() + ->label('View policy') + ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) + ->hidden(fn ($record) => ! $record->policy_id) + ->openUrlInNewTab(true), + Actions\Action::make('remove') + ->label('Remove') + ->color('danger') + ->icon('heroicon-o-x-mark') + ->requiresConfirmation() + ->action(function (BackupItem $record, AuditLogger $auditLogger) { + $record->delete(); - if ($record->backupSet) { - $record->backupSet->update([ - 'item_count' => $record->backupSet->items()->count(), - ]); - } + if ($record->backupSet) { + $record->backupSet->update([ + 'item_count' => $record->backupSet->items()->count(), + ]); + } - if ($record->tenant) { - $auditLogger->log( - tenant: $record->tenant, - action: 'backup.item_removed', - resourceType: 'backup_set', - resourceId: (string) $record->backup_set_id, - status: 'success', - context: ['metadata' => ['policy_id' => $record->policy_id]] - ); - } + if ($record->tenant) { + $auditLogger->log( + tenant: $record->tenant, + action: 'backup.item_removed', + resourceType: 'backup_set', + resourceId: (string) $record->backup_set_id, + status: 'success', + context: ['metadata' => ['policy_id' => $record->policy_id]] + ); + } - Notification::make() - ->title('Policy removed from backup') - ->success() - ->send(); - }), + Notification::make() + ->title('Policy removed from backup') + ->success() + ->send(); + }), + ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]); + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\BulkAction::make('bulk_remove') + ->label('Remove selected') + ->icon('heroicon-o-x-mark') + ->color('danger') + ->requiresConfirmation() + ->action(function (Collection $records, AuditLogger $auditLogger) { + if ($records->isEmpty()) { + return; + } + + $backupSet = $this->getOwnerRecord(); + + $records->each(fn (BackupItem $record) => $record->delete()); + + $backupSet->update([ + 'item_count' => $backupSet->items()->count(), + ]); + + $tenant = $records->first()?->tenant; + + if ($tenant) { + $auditLogger->log( + tenant: $tenant, + action: 'backup.items_removed', + resourceType: 'backup_set', + resourceId: (string) $backupSet->id, + status: 'success', + context: [ + 'metadata' => [ + 'removed_count' => $records->count(), + 'policy_ids' => $records->pluck('policy_id')->filter()->values()->all(), + 'policy_identifiers' => $records->pluck('policy_identifier')->filter()->values()->all(), + ], + ] + ); + } + + Notification::make() + ->title('Policies removed from backup') + ->success() + ->send(); + }), + ]), + ]); } /** diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php new file mode 100644 index 0000000..dc17f1e --- /dev/null +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -0,0 +1,261 @@ +backupSetId = $backupSetId; + } + + public static function externalIdShort(?string $externalId): string + { + $value = (string) ($externalId ?? ''); + + $normalized = preg_replace('/[^A-Za-z0-9]/', '', $value) ?? ''; + + if ($normalized === '') { + return '—'; + } + + return substr($normalized, -8); + } + + public function table(Table $table): Table + { + $backupSet = BackupSet::query()->find($this->backupSetId); + $tenantId = $backupSet?->tenant_id ?? Tenant::current()->getKey(); + $existingPolicyIds = $backupSet + ? $backupSet->items()->pluck('policy_id')->filter()->all() + : []; + + return $table + ->queryStringIdentifier('backupSetPolicyPicker'.Str::studly((string) $this->backupSetId)) + ->query( + Policy::query() + ->where('tenant_id', $tenantId) + ->when($existingPolicyIds !== [], fn (Builder $query) => $query->whereNotIn('id', $existingPolicyIds)) + ) + ->deferLoading(! app()->runningUnitTests()) + ->paginated([25, 50, 100]) + ->defaultPaginationPageOption(25) + ->searchable() + ->striped() + ->columns([ + TextColumn::make('display_name') + ->label('Name') + ->searchable() + ->sortable() + ->wrap(), + TextColumn::make('policy_type') + ->label('Type') + ->badge() + ->formatStateUsing(fn (?string $state): string => (string) (static::typeMeta($state)['label'] ?? $state ?? '—')), + TextColumn::make('platform') + ->label('Platform') + ->badge() + ->default('—') + ->sortable(), + TextColumn::make('external_id') + ->label('External ID') + ->formatStateUsing(fn (?string $state): string => static::externalIdShort($state)) + ->tooltip(fn (?string $state): ?string => filled($state) ? $state : null) + ->extraAttributes(['class' => 'font-mono text-xs']) + ->toggleable(), + TextColumn::make('versions_count') + ->label('Versions') + ->state(fn (Policy $record): int => (int) ($record->versions_count ?? 0)) + ->badge() + ->sortable(), + TextColumn::make('last_synced_at') + ->label('Last synced') + ->dateTime() + ->since() + ->sortable() + ->toggleable(), + TextColumn::make('ignored_at') + ->label('Ignored') + ->badge() + ->color(fn (?string $state): string => filled($state) ? 'warning' : 'gray') + ->formatStateUsing(fn (?string $state): string => filled($state) ? 'yes' : 'no') + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->withCount('versions')) + ->filters([ + SelectFilter::make('policy_type') + ->label('Policy type') + ->options(static::policyTypeOptions()), + SelectFilter::make('platform') + ->label('Platform') + ->options(fn (): array => Policy::query() + ->where('tenant_id', $tenantId) + ->whereNotNull('platform') + ->distinct() + ->orderBy('platform') + ->pluck('platform', 'platform') + ->all()), + SelectFilter::make('synced_within') + ->label('Last synced') + ->options([ + '7' => 'Within 7 days', + '30' => 'Within 30 days', + '90' => 'Within 90 days', + 'any' => 'Any time', + ]) + ->default('7') + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? '7'); + + if ($value === 'any') { + return $query; + } + + $days = is_numeric($value) ? (int) $value : 7; + + return $query->where('last_synced_at', '>', now()->subDays(max(1, $days))); + }), + TernaryFilter::make('ignored') + ->label('Ignored') + ->nullable() + ->queries( + true: fn (Builder $query) => $query->whereNotNull('ignored_at'), + false: fn (Builder $query) => $query->whereNull('ignored_at'), + ) + ->default(false), + SelectFilter::make('has_versions') + ->label('Has versions') + ->options([ + '1' => 'Has versions', + '0' => 'No versions', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + return match ((string) $value) { + '1' => $query->whereHas('versions'), + '0' => $query->whereDoesntHave('versions'), + default => $query, + }; + }), + ]) + ->bulkActions([ + BulkAction::make('add_selected_to_backup_set') + ->label('Add selected') + ->icon('heroicon-m-plus') + ->action(function (Collection $records, BackupService $service): void { + $backupSet = BackupSet::query()->findOrFail($this->backupSetId); + $tenant = $backupSet->tenant ?? Tenant::current(); + + $policyIds = $records->pluck('id')->all(); + + if ($policyIds === []) { + Notification::make() + ->title('No policies selected') + ->warning() + ->send(); + + return; + } + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: $policyIds, + actorEmail: auth()->user()?->email, + actorName: auth()->user()?->name, + includeAssignments: $this->include_assignments, + includeScopeTags: $this->include_scope_tags, + includeFoundations: $this->include_foundations, + ); + + $notificationTitle = $this->include_foundations + ? 'Backup items added' + : 'Policies added to backup'; + + Notification::make() + ->title($notificationTitle) + ->success() + ->send(); + + $this->resetTable(); + }), + ]); + } + + public function render(): View + { + return view('livewire.backup-set-policy-picker-table'); + } + + /** + * @return array{label:?string,category:?string,restore:?string,risk:?string}|array + */ + 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 + */ + private static function policyTypeOptions(): array + { + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []) + ); + + return collect($types) + ->mapWithKeys(function (array $meta): array { + $type = (string) ($meta['type'] ?? ''); + + if ($type === '') { + return []; + } + + $label = (string) ($meta['label'] ?? $type); + + return [$type => $label]; + }) + ->all(); + } +} diff --git a/resources/views/filament/modals/backup-set-policy-picker.blade.php b/resources/views/filament/modals/backup-set-policy-picker.blade.php new file mode 100644 index 0000000..8d5502f --- /dev/null +++ b/resources/views/filament/modals/backup-set-policy-picker.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/livewire/backup-set-policy-picker-table.blade.php b/resources/views/livewire/backup-set-policy-picker-table.blade.php new file mode 100644 index 0000000..2c8b681 --- /dev/null +++ b/resources/views/livewire/backup-set-policy-picker-table.blade.php @@ -0,0 +1,20 @@ +
+
+ + + + + +
+ + {{ $this->table }} +
diff --git a/specs/015-policy-picker-ux/checklists/requirements.md b/specs/015-policy-picker-ux/checklists/requirements.md new file mode 100644 index 0000000..5281408 --- /dev/null +++ b/specs/015-policy-picker-ux/checklists/requirements.md @@ -0,0 +1,30 @@ +# Specification Quality Checklist: Policy Picker UX + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-02 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification diff --git a/specs/015-policy-picker-ux/plan.md b/specs/015-policy-picker-ux/plan.md new file mode 100644 index 0000000..72ee802 --- /dev/null +++ b/specs/015-policy-picker-ux/plan.md @@ -0,0 +1,32 @@ +# Plan: Policy Picker UX (015) + +**Branch**: `015-policy-picker-ux` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Goal +Improve the “Add Policies” picker UX by making option labels self-describing (type/platform/external id) to reduce mistakes with duplicate policy names. + +## Scope + +### In scope +- Update the “Add Policies” action in the Backup Set items relation manager. +- Present the picker as a modal table (row selection). +- Table shows: display name, policy type (human label if available), platform, short external id. +- Filters: policy type, platform, last synced, ignored, has versions. +- “Select all” selects the current filtered results. +- Add a unit/feature test covering the label formatting. + +### Out of scope +- Adding filters, select-all, new pages, or additional UI flows. + +## Approach +1. Replace the Select-based picker with a Livewire/Filament table component rendered inside the action modal. +2. Add the required filters and columns. +3. Implement a bulk action to add selected policies to the backup set. +4. Add tests asserting the picker table bulk action works and filters are available. +4. Run targeted tests and Pint. + +## Success Criteria +- Picker options are clearly distinguishable for policies with duplicate names. +- Tests are green. diff --git a/specs/015-policy-picker-ux/spec.md b/specs/015-policy-picker-ux/spec.md new file mode 100644 index 0000000..2a62264 --- /dev/null +++ b/specs/015-policy-picker-ux/spec.md @@ -0,0 +1,37 @@ +# Feature Specification: Policy Picker UX (015) + +**Feature Branch**: `015-policy-picker-ux` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — Disambiguate duplicate policy names (Priority: P1) + +As an admin, I want policy options in the “Add Policies” picker to be clearly distinguishable, so I can confidently select the correct policy even when multiple policies share the same display name. + +**Acceptance Scenarios** +1. Given multiple policies with the same display name, when I open the “Add Policies” picker, then each option shows additional identifiers (type, platform, short external id). +2. Given a policy option, when I search in the picker, then results remain searchable by display name. + +### User Story 2 — Add policies efficiently (Priority: P1) + +As an admin, I want to browse and select policies in a table with filters and multi-select, so I can add the right set of policies without repetitive searching. + +**Acceptance Scenarios** +1. When I open the “Add Policies” picker, then I see a table with policy rows and selectable checkboxes. +2. When I filter by policy type / platform / last synced / ignored / has versions, then only matching policies are shown. +3. When I click “select all”, then only the currently filtered results are selected. + +## Requirements + +### Functional Requirements +- **FR-001**: The “Add Policies” picker MUST be presented as a table inside the modal. +- **FR-002**: Each policy row MUST show: display name, policy type, platform, and a short external id. +- **FR-003**: The picker MUST support multi-select. +- **FR-004**: The picker MUST provide filtering for: policy type, platform, last synced, ignored, and has versions. +- **FR-005**: The picker MUST support “select all” for the currently filtered results (not all policies in the tenant). + +## Success Criteria +- **SC-001**: In tenants with duplicate policy names, admins can identify the correct policy from the picker without trial-and-error. +- **SC-002**: Admins can add large sets of policies efficiently using filters + multi-select. diff --git a/specs/015-policy-picker-ux/tasks.md b/specs/015-policy-picker-ux/tasks.md new file mode 100644 index 0000000..7f888ed --- /dev/null +++ b/specs/015-policy-picker-ux/tasks.md @@ -0,0 +1,24 @@ +# Tasks: Policy Picker UX (015) + +**Branch**: `015-policy-picker-ux` | **Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [X] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Core +- [X] T002 Update “Add Policies” picker option labels to include type/platform/short external id. +- [X] T006 Replace picker with a modal table (multi-select). +- [X] T007 Add filters: policy type, platform, last synced, ignored, has versions. +- [X] T008 Implement “select all” for filtered results (via Filament table selection). +- [X] T012 Group row actions (View/Remove) in backup items table. +- [X] T013 Add bulk remove action for backup items. + +## Phase 3: Tests + Verification +- [X] T003 Add test coverage for policy picker option labels. +- [X] T004 Run targeted tests. +- [X] T005 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T009 Update/add tests for table picker bulk add. +- [X] T010 Run targeted tests. +- [X] T011 Run Pint (`./vendor/bin/pint --dirty`). +- [X] T014 Add test coverage for bulk remove. diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index 728a913..a08c7d5 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -8,9 +8,9 @@ use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; use App\Services\Graph\ScopeTagResolver; +use App\Services\Intune\BackupService; use App\Services\Intune\PolicySnapshotService; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; use Mockery\MockInterface; uses(RefreshDatabase::class); @@ -106,14 +106,16 @@ public function request(string $method, string $path, array $options = []): Grap 'name' => 'Test backup', ]); - Livewire::test(\App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager::class, [ - 'ownerRecord' => $backupSet, - 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, - ])->callTableAction('addPolicies', data: [ - 'policy_ids' => [$policyA->id], - 'include_assignments' => false, - 'include_scope_tags' => true, - ]); + app(BackupService::class)->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policyA->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: true, + includeFoundations: true, + ); $backupSet->refresh(); diff --git a/tests/Feature/Filament/BackupItemsBulkRemoveTest.php b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php new file mode 100644 index 0000000..a1a0ae6 --- /dev/null +++ b/tests/Feature/Filament/BackupItemsBulkRemoveTest.php @@ -0,0 +1,74 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'item_count' => 0, + ]); + + $policyA = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $policyB = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $itemA = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policyA->id, + 'policy_identifier' => $policyA->external_id, + 'policy_type' => $policyA->policy_type, + 'platform' => $policyA->platform, + ]); + + $itemB = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policyB->id, + 'policy_identifier' => $policyB->external_id, + 'policy_type' => $policyB->policy_type, + 'platform' => $policyB->platform, + ]); + + $backupSet->update(['item_count' => $backupSet->items()->count()]); + expect($backupSet->refresh()->item_count)->toBe(2); + + Livewire::actingAs($user) + ->test(BackupItemsRelationManager::class, [ + 'ownerRecord' => $backupSet, + 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, + ]) + ->callTableBulkAction('bulk_remove', collect([$itemA, $itemB])) + ->assertHasNoTableBulkActionErrors(); + + $backupSet->refresh(); + + expect($backupSet->items()->count())->toBe(0); + expect($backupSet->item_count)->toBe(0); + + $this->assertSoftDeleted('backup_items', ['id' => $itemA->id]); + $this->assertSoftDeleted('backup_items', ['id' => $itemB->id]); +}); diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php new file mode 100644 index 0000000..e207423 --- /dev/null +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -0,0 +1,97 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $policies = Policy::factory()->count(2)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($tenant, $backupSet, $policies, $user) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->withArgs(function ($tenantArg, $backupSetArg, $policyIds, $actorEmail, $actorName, $includeAssignments, $includeScopeTags, $includeFoundations) use ($tenant, $backupSet, $policies, $user) { + expect($tenantArg->id)->toBe($tenant->id); + expect($backupSetArg->id)->toBe($backupSet->id); + expect($policyIds)->toBe($policies->pluck('id')->all()); + expect($actorEmail)->toBe($user->email); + expect($actorName)->toBe($user->name); + expect($includeAssignments)->toBeTrue(); + expect($includeScopeTags)->toBeTrue(); + expect($includeFoundations)->toBeTrue(); + + return true; + }); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); +}); + +test('policy picker table can filter by has versions', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + ]); + + $withVersions = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'display_name' => 'With Versions', + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $withVersions->id, + 'policy_type' => $withVersions->policy_type, + 'platform' => $withVersions->platform, + ]); + + $withoutVersions = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'display_name' => 'Without Versions', + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->filterTable('has_versions', '1') + ->assertSee('With Versions') + ->assertDontSee('Without Versions'); +}); diff --git a/tests/Unit/PolicyPickerOptionLabelTest.php b/tests/Unit/PolicyPickerOptionLabelTest.php new file mode 100644 index 0000000..ae24d71 --- /dev/null +++ b/tests/Unit/PolicyPickerOptionLabelTest.php @@ -0,0 +1,13 @@ +toBe('1234abcd'); + + expect(\App\Livewire\BackupSetPolicyPickerTable::externalIdShort(null)) + ->toBe('—'); +}); From a8bdfc5a7744ffe1b481a6053a949eddeb526b95 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 2 Jan 2026 14:33:29 +0000 Subject: [PATCH 06/18] feat: always capture policy when adding to backup (#22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Beim Hinzufügen zu einem Backup Set kann ein lokaler “Reuse” dazu führen, dass ein Backup nicht den aktuellen Intune-Stand reflektiert, wenn last_synced_at nicht frisch ist. Lösung: BackupService führt beim Add immer orchestrated capture aus (Graph Fetch), damit “Backup = current state” gilt. Trotzdem kein unnötiges Version-Wachstum: PolicyCaptureOrchestrator re-used bestehende PolicyVersions via Snapshot-Hash, wenn sich nichts geändert hat. Tests: Added BackupServiceVersionReuseTest.php Specs: Updated spec.md + plan.md + tasks checked off. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/22 --- app/Services/Intune/BackupService.php | 60 ++++++-- .../checklists/requirements.md | 9 ++ specs/016-backup-version-reuse/plan.md | 18 +++ specs/016-backup-version-reuse/spec.md | 29 ++++ specs/016-backup-version-reuse/tasks.md | 18 +++ .../Feature/BackupServiceVersionReuseTest.php | 144 ++++++++++++++++++ 6 files changed, 262 insertions(+), 16 deletions(-) create mode 100644 specs/016-backup-version-reuse/checklists/requirements.md create mode 100644 specs/016-backup-version-reuse/plan.md create mode 100644 specs/016-backup-version-reuse/spec.md create mode 100644 specs/016-backup-version-reuse/tasks.md create mode 100644 tests/Feature/BackupServiceVersionReuseTest.php diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index 22abe4c..255e2e1 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -5,6 +5,7 @@ use App\Models\BackupItem; use App\Models\BackupSet; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\AssignmentBackupService; use Carbon\CarbonImmutable; @@ -289,13 +290,46 @@ private function snapshotPolicy( $captured = $captureResult['captured']; $payload = $captured['payload']; $metadata = $captured['metadata'] ?? []; - $metadataWarnings = $captured['warnings'] ?? []; - // Validate snapshot - $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); + return [ + $this->createBackupItemFromVersion( + tenant: $tenant, + backupSet: $backupSet, + policy: $policy, + version: $version, + payload: is_array($payload) ? $payload : [], + assignments: $captured['assignments'] ?? null, + scopeTags: $captured['scope_tags'] ?? null, + metadata: is_array($metadata) ? $metadata : [], + warnings: $captured['warnings'] ?? [], + ), + null, + ]; + } + + /** + * @param array $payload + * @param array $metadata + * @param array $warnings + * @param array{ids:array,names:array}|null $scopeTags + */ + private function createBackupItemFromVersion( + Tenant $tenant, + BackupSet $backupSet, + Policy $policy, + PolicyVersion $version, + array $payload, + ?array $assignments, + ?array $scopeTags, + array $metadata, + array $warnings = [], + ): BackupItem { + $metadataWarnings = $warnings; + + $validation = $this->snapshotValidator->validate($payload); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); - $odataWarning = BackupItem::odataTypeWarning(is_array($payload) ? $payload : [], $policy->policy_type, $policy->platform); + $odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform); if ($odataWarning) { $metadataWarnings[] = $odataWarning; @@ -305,29 +339,23 @@ private function snapshotPolicy( $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } - $capturedScopeTags = $captured['scope_tags'] ?? null; - if (is_array($capturedScopeTags)) { - $metadata['scope_tag_ids'] = $capturedScopeTags['ids'] ?? null; - $metadata['scope_tag_names'] = $capturedScopeTags['names'] ?? null; + if (is_array($scopeTags)) { + $metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null; + $metadata['scope_tag_names'] = $scopeTags['names'] ?? null; } - // Create BackupItem as a copy/reference of the PolicyVersion - $backupItem = BackupItem::create([ + return BackupItem::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, - 'policy_version_id' => $version->id, // Link to version + 'policy_version_id' => $version->id, 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'payload' => $payload, 'metadata' => $metadata, - // Copy assignments from version (already captured) - // Note: scope_tags are only stored in PolicyVersion - 'assignments' => $captured['assignments'] ?? null, + 'assignments' => $assignments, ]); - - return [$backupItem, null]; } /** diff --git a/specs/016-backup-version-reuse/checklists/requirements.md b/specs/016-backup-version-reuse/checklists/requirements.md new file mode 100644 index 0000000..9452bf7 --- /dev/null +++ b/specs/016-backup-version-reuse/checklists/requirements.md @@ -0,0 +1,9 @@ +# Specification Quality Checklist: Backup Version Reuse + +**Created**: 2026-01-02 +**Feature**: [spec.md](../spec.md) + +- [x] User story and acceptance scenarios defined +- [x] Requirements are testable and unambiguous +- [x] Scope bounded +- [x] No implementation details required by spec diff --git a/specs/016-backup-version-reuse/plan.md b/specs/016-backup-version-reuse/plan.md new file mode 100644 index 0000000..e123e5b --- /dev/null +++ b/specs/016-backup-version-reuse/plan.md @@ -0,0 +1,18 @@ +# Plan: Backup Version Reuse (016) + +**Branch**: `016-backup-version-reuse` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Goal +Reduce unnecessary `PolicyVersion` creation when policies are added to backup sets by reusing an existing suitable latest version where safe. + +## Approach +1. Always capture from Intune when a policy is added to a backup set (admin expectation: "backup = current state"). +2. Rely on `PolicyCaptureOrchestrator` snapshot-hash reuse to avoid redundant `PolicyVersion` creation when nothing changed. +3. Still respect capture options (assignments / scope tags) via orchestrator backfill behavior. +4. Add tests for both reuse and capture paths. + +## Out of scope +- UI toggles/config flags unless required. +- Cross-policy dedup or historical compaction. diff --git a/specs/016-backup-version-reuse/spec.md b/specs/016-backup-version-reuse/spec.md new file mode 100644 index 0000000..35756a4 --- /dev/null +++ b/specs/016-backup-version-reuse/spec.md @@ -0,0 +1,29 @@ +# Feature Specification: Backup Version Reuse (016) + +**Feature Branch**: `016-backup-version-reuse` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — Avoid unnecessary version growth (Priority: P1) +As an admin, I want adding policies to a backup set to reuse an existing recent policy version when safe, so backups don’t create redundant versions and operations stay fast. + +**Acceptance Scenarios** +1. Given a policy already has an identical captured snapshot, when I add it to a backup set, then the backup item links to the existing version (no new version is created). +2. Given a policy has no suitable version, when I add it to a backup set, then a new version is captured and linked. + +## Requirements + +### Functional Requirements +- **FR-001**: Adding policies to a backup set SHOULD avoid creating redundant `PolicyVersion` records by reusing an existing version when the captured snapshot is unchanged. +- **FR-002**: If reuse is not safe/possible, the system MUST capture a new `PolicyVersion` as it does today. +- **FR-003**: Reuse MUST respect capture options: + - If assignments are requested, the reused version must include assignments. + - If scope tags are requested, the reused version must include scope tags. +- **FR-005**: Adding a policy to a backup set MUST capture from Intune to ensure the backup reflects the current state. +- **FR-004**: Behavior changes MUST be covered by automated tests. + +## Success Criteria +- **SC-001**: Backups avoid creating redundant policy versions in the common case. +- **SC-002**: Backup correctness is preserved (no missing required data for restore/preview). diff --git a/specs/016-backup-version-reuse/tasks.md b/specs/016-backup-version-reuse/tasks.md new file mode 100644 index 0000000..8e85570 --- /dev/null +++ b/specs/016-backup-version-reuse/tasks.md @@ -0,0 +1,18 @@ +# Tasks: Backup Version Reuse (016) + +**Branch**: `016-backup-version-reuse` | **Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [X] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Tests (TDD) +- [X] T002 Add tests for reusing an existing suitable PolicyVersion. +- [X] T003 Add tests for capturing a new PolicyVersion when reuse is not possible. + +## Phase 3: Core +- [X] T004 Implement reuse decision + reuse path in BackupService. + +## Phase 4: Verification +- [X] T005 Run targeted tests. +- [X] T006 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/BackupServiceVersionReuseTest.php b/tests/Feature/BackupServiceVersionReuseTest.php new file mode 100644 index 0000000..32f2d12 --- /dev/null +++ b/tests/Feature/BackupServiceVersionReuseTest.php @@ -0,0 +1,144 @@ +create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'last_synced_at' => now(), + 'ignored_at' => null, + ]); + + $existingVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'name' => $policy->display_name], + 'assignments' => null, + 'scope_tags' => null, + ]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($existingVersion) { + $mock->shouldReceive('capture') + ->once() + ->andReturn([ + 'version' => $existingVersion, + 'captured' => [ + 'payload' => $existingVersion->snapshot, + 'assignments' => $existingVersion->assignments, + 'scope_tags' => $existingVersion->scope_tags, + 'metadata' => [], + ], + ]); + }); + + $service = app(BackupService::class); + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policy->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + expect(PolicyVersion::query()->where('policy_id', $policy->id)->count())->toBe(1); + + $item = $backupSet->items()->first(); + expect($item)->not->toBeNull(); + expect($item->policy_version_id)->toBe($existingVersion->id); +}); + +it('captures a new policy version for backup when no suitable existing version is available', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'last_synced_at' => now(), + 'ignored_at' => null, + ]); + + $staleVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(2), + 'snapshot' => ['id' => $policy->external_id, 'name' => $policy->display_name], + ]); + + $policy->update(['last_synced_at' => now()]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policy, $tenant) { + $mock->shouldReceive('capture') + ->once() + ->andReturnUsing(function () use ($policy, $tenant) { + $newVersion = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 2, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'name' => $policy->display_name, 'changed' => true], + ]); + + return [ + 'version' => $newVersion, + 'captured' => [ + 'payload' => $newVersion->snapshot, + 'assignments' => null, + 'scope_tags' => null, + 'metadata' => [], + ], + ]; + }); + }); + + $service = app(BackupService::class); + + $service->addPoliciesToSet( + tenant: $tenant, + backupSet: $backupSet, + policyIds: [$policy->id], + actorEmail: $user->email, + actorName: $user->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + $item = $backupSet->items()->first(); + expect($item)->not->toBeNull(); + expect($item->policy_version_id)->not->toBe($staleVersion->id); +}); From 412dd7ad66793c9f7a9f97f318b39d61ae1738e2 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 3 Jan 2026 02:06:35 +0000 Subject: [PATCH 07/18] feat/017-policy-types-mam-endpoint-security-baselines (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hydrate configurationPolicies/{id}/settings for endpoint security/baseline policies so snapshots include real rule data. Treat those types like Settings Catalog policies in the normalizer so they show the searchable settings table, recognizable categories, and readable choice values (firewall-specific formatting + interface badge parsing). Improve “General” tab cards: badge lists for platforms/technologies, template reference summary (name/family/version/ID), and ISO timestamps rendered as YYYY‑MM‑DD HH:MM:SS; added regression test for the view. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/23 --- app/Filament/Resources/PolicyResource.php | 36 ++- .../PolicyResource/Pages/ListPolicies.php | 28 +- .../PolicyResource/Pages/ViewPolicy.php | 23 +- .../VersionsRelationManager.php | 2 + .../Resources/PolicyVersionResource.php | 6 +- app/Livewire/BackupSetPolicyPickerTable.php | 24 +- app/Providers/AppServiceProvider.php | 2 + app/Services/Graph/GraphContractRegistry.php | 10 + app/Services/Graph/MicrosoftGraphClient.php | 228 +++++++++++++- .../Intune/DefaultPolicyNormalizer.php | 166 +++++++++- ...anagedDeviceAppConfigurationNormalizer.php | 143 +++++++++ .../Intune/PolicyCaptureOrchestrator.php | 16 +- app/Services/Intune/PolicySnapshotService.php | 132 +++++++- app/Services/Intune/PolicySyncService.php | 180 +++++++++-- app/Services/Intune/RestoreRiskChecker.php | 86 +++++ app/Services/Intune/RestoreService.php | 12 + .../SettingsCatalogDefinitionResolver.php | 39 +++ .../SettingsCatalogPolicyNormalizer.php | 2 +- app/Services/Intune/VersionService.php | 12 +- config/graph_contracts.php | 109 ++++++- config/tenantpilot.php | 42 ++- .../entries/policy-general.blade.php | 78 ++++- .../policy-settings-standard.blade.php | 46 ++- .../checklists/requirements.md | 7 + .../plan.md | 41 +++ .../spec.md | 47 +++ .../tasks.md | 56 ++++ .../BackupSetPolicyPickerTableTest.php | 99 ++++++ ...ingsCatalogPolicyNormalizedDisplayTest.php | 12 +- tests/Feature/PolicyGeneralViewTest.php | 41 +++ .../PolicySettingsStandardViewTest.php | 30 ++ ...rollmentConfigurationTypeCollisionTest.php | 74 +++++ tests/Feature/PolicySyncServiceReportTest.php | 61 ++++ tests/Feature/PolicySyncServiceTest.php | 219 +++++++++++++ tests/Feature/PolicyTypes017Test.php | 267 ++++++++++++++++ tests/Feature/RestoreRiskChecksWizardTest.php | 73 +++++ .../VersionCaptureMetadataOnlyTest.php | 66 ++++ .../GraphClientEndpointResolutionTest.php | 63 ++++ ...edDeviceAppConfigurationNormalizerTest.php | 45 +++ ...osoftGraphClientListPoliciesSelectTest.php | 160 ++++++++++ tests/Unit/PolicyCaptureOrchestratorTest.php | 64 ++++ tests/Unit/PolicySnapshotServiceTest.php | 293 ++++++++++++++++++ .../SettingsCatalogPolicyNormalizerTest.php | 136 ++++++++ 43 files changed, 3175 insertions(+), 101 deletions(-) create mode 100644 app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/plan.md create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/spec.md create mode 100644 specs/017-policy-types-mam-endpoint-security-baselines/tasks.md create mode 100644 tests/Feature/PolicyGeneralViewTest.php create mode 100644 tests/Feature/PolicySettingsStandardViewTest.php create mode 100644 tests/Feature/PolicySyncServiceReportTest.php create mode 100644 tests/Feature/PolicyTypes017Test.php create mode 100644 tests/Feature/VersionCaptureMetadataOnlyTest.php create mode 100644 tests/Unit/GraphClientEndpointResolutionTest.php create mode 100644 tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php create mode 100644 tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php create mode 100644 tests/Unit/PolicyCaptureOrchestratorTest.php diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index bbfac49..371b8cc 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -58,6 +58,26 @@ public static function infolist(Schema $schema): Schema TextEntry::make('external_id')->label('External ID'), TextEntry::make('last_synced_at')->dateTime()->label('Last synced'), TextEntry::make('created_at')->since(), + TextEntry::make('latest_snapshot_mode') + ->label('Snapshot') + ->badge() + ->color(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'warning' : 'success') + ->state(fn (Policy $record): string => (static::latestVersionMetadata($record)['source'] ?? null) === 'metadata_only' ? 'metadata only' : 'full') + ->helperText(function (Policy $record): ?string { + $meta = static::latestVersionMetadata($record); + + if (($meta['source'] ?? null) !== 'metadata_only') { + return null; + } + + $status = $meta['original_status'] ?? null; + + return sprintf( + 'Graph returned %s for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.', + $status ?? 'an error' + ); + }) + ->visible(fn (Policy $record) => $record->versions()->exists()), ]) ->columns(2) ->columnSpanFull(), @@ -597,6 +617,20 @@ private static function latestSnapshot(Policy $record): array return []; } + private static function latestVersionMetadata(Policy $record): array + { + $metadata = $record->relationLoaded('versions') + ? $record->versions->first()?->metadata + : $record->versions()->orderByDesc('captured_at')->value('metadata'); + + if (is_string($metadata)) { + $decoded = json_decode($metadata, true); + $metadata = $decoded ?? []; + } + + return is_array($metadata) ? $metadata : []; + } + /** * @return array */ @@ -764,7 +798,7 @@ private static function settingsTabState(Policy $record): array $rows = $normalized['settings_table']['rows'] ?? []; $hasSettingsTable = is_array($rows) && $rows !== []; - if ($record->policy_type === 'settingsCatalogPolicy' && $hasSettingsTable) { + if (in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true) && $hasSettingsTable) { $split = static::splitGeneralBlock($normalized); return $split['normalized']; diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index e3743d2..222510c 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -28,11 +28,35 @@ protected function getHeaderActions(): array /** @var PolicySyncService $service */ $service = app(PolicySyncService::class); - $synced = $service->syncPolicies($tenant); + $result = $service->syncPoliciesWithReport($tenant); + $syncedCount = count($result['synced'] ?? []); + $failureCount = count($result['failures'] ?? []); + + $body = $syncedCount.' policies synced'; + + if ($failureCount > 0) { + $first = $result['failures'][0] ?? []; + $firstType = $first['policy_type'] ?? 'unknown'; + $firstStatus = $first['status'] ?? null; + + $firstErrorMessage = null; + $firstErrors = $first['errors'] ?? null; + if (is_array($firstErrors) && isset($firstErrors[0]) && is_array($firstErrors[0])) { + $firstErrorMessage = $firstErrors[0]['message'] ?? null; + } + + $suffix = $firstStatus ? "first: {$firstType} {$firstStatus}" : "first: {$firstType}"; + + if (is_string($firstErrorMessage) && $firstErrorMessage !== '') { + $suffix .= ' - '.trim($firstErrorMessage); + } + + $body .= " ({$failureCount} failed; {$suffix})"; + } Notification::make() ->title('Policy sync completed') - ->body(count($synced).' policies synced') + ->body($body) ->success() ->sendToDatabase(auth()->user()) ->send(); diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index 17c7b1b..1bfd70a 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -49,7 +49,7 @@ protected function getActions(): array return; } - app(VersionService::class)->captureFromGraph( + $version = app(VersionService::class)->captureFromGraph( tenant: $tenant, policy: $policy, createdBy: auth()->user()?->email ?? null, @@ -57,10 +57,23 @@ protected function getActions(): array includeScopeTags: $data['include_scope_tags'] ?? false, ); - Notification::make() - ->title('Snapshot captured successfully.') - ->success() - ->send(); + if (($version->metadata['source'] ?? null) === 'metadata_only') { + $status = $version->metadata['original_status'] ?? null; + + Notification::make() + ->title('Snapshot captured (metadata only)') + ->body(sprintf( + 'Microsoft Graph returned %s for this policy type, so only local metadata was saved. Full restore is not possible until Graph works again.', + $status ?? 'an error' + )) + ->warning() + ->send(); + } else { + Notification::make() + ->title('Snapshot captured successfully.') + ->success() + ->send(); + } $this->redirect($this->getResource()::getUrl('view', ['record' => $policy->getKey()])); } catch (\Throwable $e) { diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 56f42cc..5a340ab 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -34,6 +34,8 @@ public function table(Table $table): Table ->label('Restore to Intune') ->icon('heroicon-o-arrow-path-rounded-square') ->color('danger') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') + ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") ->modalSubheading('Creates a restore run using this policy version snapshot.') diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 2b01621..b9dd732 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -74,7 +74,7 @@ public static function infolist(Schema $schema): Schema return $normalized; }) - ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') === 'settingsCatalogPolicy'), + ->visible(fn (PolicyVersion $record) => in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), Infolists\Components\ViewEntry::make('normalized_settings_standard') ->view('filament.infolists.entries.policy-settings-standard') @@ -91,7 +91,7 @@ public static function infolist(Schema $schema): Schema return $normalized; }) - ->visible(fn (PolicyVersion $record) => ($record->policy_type ?? '') !== 'settingsCatalogPolicy'), + ->visible(fn (PolicyVersion $record) => ! in_array($record->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)), ]), Tab::make('Raw JSON') ->id('raw-json') @@ -194,6 +194,8 @@ public static function table(Table $table): Table ->label('Restore via Wizard') ->icon('heroicon-o-arrow-path-rounded-square') ->color('primary') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') + ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.') diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index dc17f1e..fdf340c 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -175,6 +175,9 @@ public function table(Table $table): Table $backupSet = BackupSet::query()->findOrFail($this->backupSetId); $tenant = $backupSet->tenant ?? Tenant::current(); + $beforeFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []); + $beforeFailureCount = count($beforeFailures); + $policyIds = $records->pluck('id')->all(); if ($policyIds === []) { @@ -201,10 +204,23 @@ public function table(Table $table): Table ? 'Backup items added' : 'Policies added to backup'; - Notification::make() - ->title($notificationTitle) - ->success() - ->send(); + $backupSet->refresh(); + + $afterFailures = (array) (($backupSet->metadata ?? [])['failures'] ?? []); + $afterFailureCount = count($afterFailures); + + if ($afterFailureCount > $beforeFailureCount) { + Notification::make() + ->title($notificationTitle.' with failures') + ->body('Some policies could not be captured from Microsoft Graph. Check the backup set failures list for details.') + ->warning() + ->send(); + } else { + Notification::make() + ->title($notificationTitle) + ->success() + ->send(); + } $this->resetTable(); }), diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 10e2a8e..2bb90ea 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,6 +10,7 @@ use App\Services\Intune\DeviceConfigurationPolicyNormalizer; use App\Services\Intune\EnrollmentAutopilotPolicyNormalizer; use App\Services\Intune\GroupPolicyConfigurationNormalizer; +use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; @@ -45,6 +46,7 @@ public function register(): void DeviceConfigurationPolicyNormalizer::class, EnrollmentAutopilotPolicyNormalizer::class, GroupPolicyConfigurationNormalizer::class, + ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 9d07ff4..22f85c9 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -32,6 +32,16 @@ public function sanitizeQuery(string $policyType, array $query): array : array_map('trim', explode(',', (string) $original)); $filtered = array_values(array_intersect($select, $allowedSelect)); + $withoutAnnotations = array_values(array_filter( + $filtered, + static fn ($field) => is_string($field) && ! str_contains($field, '@') + )); + + if (count($withoutAnnotations) !== count($filtered)) { + $warnings[] = 'Removed OData annotation fields from $select (unsupported by Graph).'; + $filtered = $withoutAnnotations; + } + if (count($filtered) !== count($select)) { $warnings[] = 'Trimmed unsupported $select fields for capability safety.'; } diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 897e237..3f47bab 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -14,6 +14,8 @@ class MicrosoftGraphClient implements GraphClientInterface { private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; + private const MAX_LIST_PAGES = 50; + private string $baseUrl; private string $tokenUrlTemplate; @@ -51,12 +53,21 @@ public function __construct( public function listPolicies(string $policyType, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType); - $query = array_filter([ + $contract = $this->contracts->get($policyType); + $allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : []; + $defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null); + + $queryInput = array_filter([ '$top' => $options['top'] ?? null, '$filter' => $options['filter'] ?? null, + '$select' => $defaultSelect, 'platform' => $options['platform'] ?? null, ], fn ($value) => $value !== null && $value !== ''); + $sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput); + $query = $sanitized['query']; + $warnings = $sanitized['warnings']; + $context = $this->resolveContext($options); $clientRequestId = $options['client_request_id'] ?? (string) Str::uuid(); $fullPath = $this->buildFullPath($endpoint, $query); @@ -79,19 +90,178 @@ public function listPolicies(string $policyType, array $options = []): GraphResp $response = $this->send('GET', $endpoint, $sendOptions, $context); - return $this->toGraphResponse( - action: 'list_policies', - response: $response, - transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), - meta: [ - 'tenant' => $context['tenant'] ?? null, - 'path' => $endpoint, - 'full_path' => $fullPath, + if ($response->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $response, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: $warnings, + ); + + if (! $this->shouldApplySelectFallback($graphResponse, $query)) { + return $graphResponse; + } + + $fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH); + $fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery); + $fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId]; + + if (isset($options['access_token'])) { + $fallbackSendOptions['access_token'] = $options['access_token']; + } + + $this->logger->logRequest('list_policies_fallback', [ + 'endpoint' => $endpoint, + 'full_path' => $fallbackPath, 'method' => 'GET', - 'query' => $query ?: null, + 'policy_type' => $policyType, + 'tenant' => $context['tenant'], + 'query' => $fallbackQuery ?: null, 'client_request_id' => $clientRequestId, - ] + ]); + + $fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context); + + if ($fallbackResponse->failed()) { + return $this->toGraphResponse( + action: 'list_policies', + response: $fallbackResponse, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fallbackPath, + 'method' => 'GET', + 'query' => $fallbackQuery ?: null, + 'client_request_id' => $clientRequestId, + ], + warnings: array_values(array_unique(array_merge( + $warnings, + ['Capability fallback applied: removed $select for compatibility.'] + ))), + ); + } + + $response = $fallbackResponse; + $query = $fallbackQuery; + $fullPath = $fallbackPath; + $warnings = array_values(array_unique(array_merge( + $warnings, + ['Capability fallback applied: removed $select for compatibility.'] + ))); + } + + $json = $response->json() ?? []; + $policies = $json['value'] ?? (is_array($json) ? $json : []); + $nextLink = $json['@odata.nextLink'] ?? null; + $pages = 1; + + while (is_string($nextLink) && $nextLink !== '') { + if ($pages >= self::MAX_LIST_PAGES) { + $graphResponse = new GraphResponse( + success: false, + data: [], + status: 500, + errors: [[ + 'message' => 'Graph pagination exceeded maximum page limit.', + 'max_pages' => self::MAX_LIST_PAGES, + ]], + warnings: $warnings, + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + 'pages_fetched' => $pages, + ], + ); + + $this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta); + + return $graphResponse; + } + + $pageOptions = ['client_request_id' => $clientRequestId]; + + if (isset($options['access_token'])) { + $pageOptions['access_token'] = $options['access_token']; + } + + $pageResponse = $this->send('GET', $nextLink, $pageOptions, $context); + + if ($pageResponse->failed()) { + $graphResponse = $this->toGraphResponse( + action: 'list_policies', + response: $pageResponse, + transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []), + meta: [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + 'pages_fetched' => $pages, + ], + warnings: array_values(array_unique(array_merge( + $warnings, + ['Pagination failed while listing policies.'] + ))), + ); + + return $graphResponse; + } + + $pageJson = $pageResponse->json() ?? []; + $pageValue = $pageJson['value'] ?? []; + + if (is_array($pageValue) && $pageValue !== []) { + $policies = array_merge($policies, $pageValue); + } + + $nextLink = $pageJson['@odata.nextLink'] ?? null; + $pages++; + } + + $meta = $this->responseMeta($response, [ + 'tenant' => $context['tenant'] ?? null, + 'path' => $endpoint, + 'full_path' => $fullPath, + 'method' => 'GET', + 'query' => $query ?: null, + 'client_request_id' => $clientRequestId, + ]); + + $meta['pages_fetched'] = $pages; + $meta['item_count'] = count($policies); + + if ($pages > 1) { + $warnings = array_values(array_unique(array_merge($warnings, [ + sprintf('Pagination applied: fetched %d pages.', $pages), + ]))); + } + + $graphResponse = new GraphResponse( + success: true, + data: $policies, + status: $response->status(), + warnings: $warnings, + meta: $meta, ); + + $this->logger->logResponse('list_policies', $graphResponse, $meta); + + return $graphResponse; } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse @@ -182,6 +352,37 @@ public function getPolicy(string $policyType, string $policyId, array $options = return $graphResponse; } + private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool + { + if (! $graphResponse->failed()) { + return false; + } + + if (($graphResponse->status ?? null) !== 400) { + return false; + } + + if (! array_key_exists('$select', $query)) { + return false; + } + + $errorMessage = $graphResponse->meta['error_message'] ?? null; + + if (! is_string($errorMessage) || $errorMessage === '') { + return false; + } + + if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) { + return true; + } + + if (stripos($errorMessage, 'Could not find a property named') !== false) { + return true; + } + + return false; + } + public function getOrganization(array $options = []): GraphResponse { $context = $this->resolveContext($options); @@ -575,6 +776,11 @@ private function normalizeScopes(array|string|null $scope): array private function endpointFor(string $policyType): string { + $contractResource = $this->contracts->resourcePath($policyType); + if (is_string($contractResource) && $contractResource !== '') { + return $contractResource; + } + $supported = config('tenantpilot.supported_policy_types', []); foreach ($supported as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { diff --git a/app/Services/Intune/DefaultPolicyNormalizer.php b/app/Services/Intune/DefaultPolicyNormalizer.php index f890a10..067092c 100644 --- a/app/Services/Intune/DefaultPolicyNormalizer.php +++ b/app/Services/Intune/DefaultPolicyNormalizer.php @@ -35,6 +35,8 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $resultWarnings = []; $status = 'success'; $settingsTable = null; + $usesSettingsCatalogTable = in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); + $fallbackCategoryName = $this->extractConfigurationPolicyFallbackCategoryName($snapshot); $validation = $this->validator->validate($snapshot); $resultWarnings = array_merge($resultWarnings, $validation['warnings']); @@ -60,23 +62,30 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor } if (isset($snapshot['settings']) && is_array($snapshot['settings'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settings']); + if ($usesSettingsCatalogTable) { + $normalized = $this->buildSettingsCatalogSettingsTable( + $snapshot['settings'], + fallbackCategoryName: $fallbackCategoryName + ); $settingsTable = $normalized['table']; $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); } else { $settings[] = $this->normalizeSettingsCatalog($snapshot['settings']); } } elseif (isset($snapshot['settingsDelta']) && is_array($snapshot['settingsDelta'])) { - if ($policyType === 'settingsCatalogPolicy') { - $normalized = $this->buildSettingsCatalogSettingsTable($snapshot['settingsDelta'], 'Settings delta'); + if ($usesSettingsCatalogTable) { + $normalized = $this->buildSettingsCatalogSettingsTable( + $snapshot['settingsDelta'], + 'Settings delta', + $fallbackCategoryName + ); $settingsTable = $normalized['table']; $resultWarnings = array_merge($resultWarnings, $normalized['warnings']); } else { $settings[] = $this->normalizeSettingsCatalog($snapshot['settingsDelta'], 'Settings delta'); } - } elseif ($policyType === 'settingsCatalogPolicy') { - $resultWarnings[] = 'Settings not hydrated for this Settings Catalog policy.'; + } elseif ($usesSettingsCatalogTable) { + $resultWarnings[] = 'Settings not hydrated for this Configuration Policy.'; } $settings[] = $this->normalizeStandard($snapshot); @@ -231,13 +240,41 @@ private function normalizeSettingsCatalog(array $settings, string $title = 'Sett ]; } + private function extractConfigurationPolicyFallbackCategoryName(array $snapshot): ?string + { + $templateReference = $snapshot['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + return null; + } + + $displayName = $templateReference['templateDisplayName'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + return $displayName; + } + + $family = $templateReference['templateFamily'] ?? null; + + if (is_string($family) && $family !== '') { + return Str::headline($family); + } + + return null; + } + /** * @param array $settings * @return array{table: array, warnings: array} */ - private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings'): array + private function buildSettingsCatalogSettingsTable(array $settings, string $title = 'Settings', ?string $fallbackCategoryName = null): array { - $flattened = $this->flattenSettingsCatalogSettingInstances($settings); + $flattened = $this->flattenSettingsCatalogSettingInstances($settings, $fallbackCategoryName); return [ 'table' => [ @@ -252,7 +289,7 @@ private function buildSettingsCatalogSettingsTable(array $settings, string $titl * @param array $settings * @return array{rows: array>, warnings: array} */ - private function flattenSettingsCatalogSettingInstances(array $settings): array + private function flattenSettingsCatalogSettingInstances(array $settings, ?string $fallbackCategoryName = null): array { $rows = []; $warnings = []; @@ -292,7 +329,8 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array &$warnedRowLimit, $definitions, $categories, - $defaultCategoryName + $defaultCategoryName, + $fallbackCategoryName, ): void { if ($rowCount >= self::SETTINGS_CATALOG_MAX_ROWS) { if (! $warnedRowLimit) { @@ -364,6 +402,16 @@ private function flattenSettingsCatalogSettingInstances(array $settings): array $categoryName = $defaultCategoryName; } + if ( + $categoryName === '-' + && is_string($fallbackCategoryName) + && $fallbackCategoryName !== '' + && is_array($definition) + && ($definition['isFallback'] ?? false) + ) { + $categoryName = $fallbackCategoryName; + } + // Convert technical type to user-friendly data type $dataType = $this->getUserFriendlyDataType($rawInstanceType, $value); @@ -516,11 +564,41 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance): $type = $instance['@odata.type'] ?? null; $type = is_string($type) ? $type : ''; + if (Str::contains($type, 'ChoiceSettingCollectionInstance', ignoreCase: true)) { + $collection = $instance['choiceSettingCollectionValue'] ?? null; + + if (! is_array($collection) || $collection === []) { + return []; + } + + $values = []; + + foreach ($collection as $item) { + if (! is_array($item)) { + continue; + } + + $value = $item['value'] ?? null; + + if (is_string($value) && $value !== '') { + $values[] = $value; + } + } + + return array_values(array_unique($values)); + } + if (Str::contains($type, 'SimpleSettingInstance', ignoreCase: true)) { $simple = $instance['simpleSettingValue'] ?? null; if (is_array($simple)) { - return $simple['value'] ?? $simple; + $simpleValue = $simple['value'] ?? $simple; + + if (is_array($simpleValue) && array_key_exists('value', $simpleValue)) { + return $simpleValue['value']; + } + + return $simpleValue; } return $simple; @@ -530,7 +608,13 @@ private function extractSettingsCatalogValue(array $setting, ?array $instance): $choice = $instance['choiceSettingValue'] ?? null; if (is_array($choice)) { - return $choice['value'] ?? $choice; + $choiceValue = $choice['value'] ?? $choice; + + if (is_array($choiceValue) && array_key_exists('value', $choiceValue)) { + return $choiceValue['value']; + } + + return $choiceValue; } return $choice; @@ -748,11 +832,17 @@ private function formatSettingsCatalogValue(mixed $value): string if (is_string($value)) { // Remove {tenantid} placeholder $value = str_replace(['{tenantid}', '_tenantid_'], ['', '_'], $value); + $value = preg_replace('/\{[^}]+\}/', '', $value); $value = preg_replace('/_+/', '_', $value); // Extract choice label from choice values (last meaningful part) // Example: "device_vendor_msft_...lowercaseletters_0" -> "Lowercase Letters: 0" - if (str_contains($value, 'device_vendor_msft') || str_contains($value, 'user_vendor_msft') || str_contains($value, '#microsoft.graph')) { + if ( + str_contains($value, 'device_vendor_msft') + || str_contains($value, 'user_vendor_msft') + || str_contains($value, 'vendor_msft') + || str_contains($value, '#microsoft.graph') + ) { $parts = explode('_', $value); $lastPart = end($parts); @@ -761,6 +851,29 @@ private function formatSettingsCatalogValue(mixed $value): string return strtolower($lastPart) === 'true' ? 'Enabled' : 'Disabled'; } + $commonLastPartMapping = [ + 'in' => 'Inbound', + 'out' => 'Outbound', + 'allow' => 'Allow', + 'block' => 'Block', + 'tcp' => 'TCP', + 'udp' => 'UDP', + 'icmpv4' => 'ICMPv4', + 'icmpv6' => 'ICMPv6', + 'any' => 'Any', + 'notconfigured' => 'Not configured', + 'lan' => 'LAN', + 'wireless' => 'Wireless', + 'remoteaccess' => 'Remote access', + 'domain' => 'Domain', + 'private' => 'Private', + 'public' => 'Public', + ]; + + if (is_string($lastPart) && isset($commonLastPartMapping[strtolower($lastPart)])) { + return $commonLastPartMapping[strtolower($lastPart)]; + } + // If last part is just a number, take second-to-last too if (is_numeric($lastPart) && count($parts) > 1) { $secondLast = $parts[count($parts) - 2]; @@ -792,6 +905,33 @@ private function formatSettingsCatalogValue(mixed $value): string } if (is_array($value)) { + if ($value === []) { + return '-'; + } + + if (array_is_list($value)) { + $parts = []; + + foreach ($value as $item) { + if ($item === null) { + continue; + } + + if (! is_bool($item) && ! is_int($item) && ! is_float($item) && ! is_string($item)) { + $parts = []; + break; + } + + $parts[] = $this->formatSettingsCatalogValue($item); + } + + $parts = array_values(array_unique(array_filter($parts, static fn (string $part): bool => $part !== '' && $part !== '-'))); + + if ($parts !== []) { + return implode(', ', $parts); + } + } + return json_encode($value); } diff --git a/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php b/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php new file mode 100644 index 0000000..27be266 --- /dev/null +++ b/app/Services/Intune/ManagedDeviceAppConfigurationNormalizer.php @@ -0,0 +1,143 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $normalized['settings'] = array_values(array_filter( + $normalized['settings'], + static function (array $block): bool { + $title = strtolower((string) ($block['title'] ?? '')); + + return $title !== 'settings' && $title !== 'settings delta'; + } + )); + + $rows = $this->buildSettingsRows($snapshot['settings'] ?? null); + + if ($rows !== []) { + $normalized['settings'][] = [ + 'type' => 'table', + 'title' => 'App configuration settings', + 'rows' => $rows, + ]; + } else { + $normalized['warnings'][] = 'No app configuration settings were returned by Graph. Intune only returns configured keys; items shown as "Not configured" in the portal are typically absent.'; + $normalized['warnings'] = array_values(array_unique(array_filter($normalized['warnings'], static fn ($value) => is_string($value) && $value !== ''))); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @return array> + */ + private function buildSettingsRows(mixed $settings): array + { + if (! is_array($settings) || $settings === []) { + return []; + } + + $rows = []; + + foreach ($settings as $setting) { + if (! is_array($setting)) { + continue; + } + + $key = $setting['appConfigKey'] ?? null; + $rawValue = $setting['appConfigKeyValue'] ?? null; + $type = $setting['appConfigKeyType'] ?? null; + + if (! is_string($key) || $key === '') { + continue; + } + + $value = $this->normalizeValue($rawValue, $type); + + $rows[] = [ + 'path' => $key, + 'label' => $key, + 'value' => is_scalar($value) || $value === null ? $value : json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'description' => is_string($type) && $type !== '' ? Str::headline($type) : null, + ]; + } + + return $rows; + } + + private function normalizeValue(mixed $value, mixed $type): mixed + { + $type = is_string($type) ? strtolower($type) : ''; + + if (is_bool($value)) { + return $value; + } + + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_string($value)) { + $trimmed = trim($value); + + if ($type !== '' && str_contains($type, 'boolean')) { + if (in_array(strtolower($trimmed), ['true', 'false'], true)) { + return strtolower($trimmed) === 'true'; + } + + if (in_array(strtolower($trimmed), ['yes', 'no'], true)) { + return strtolower($trimmed) === 'yes'; + } + + if (in_array($trimmed, ['1', '0'], true)) { + return $trimmed === '1'; + } + } + + if ($type !== '' && (str_contains($type, 'integer') || str_contains($type, 'int'))) { + if (is_numeric($trimmed) && (string) (int) $trimmed === $trimmed) { + return (int) $trimmed; + } + } + + return $trimmed; + } + + return $value; + } +} diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php index c495b73..b7a5f78 100644 --- a/app/Services/Intune/PolicyCaptureOrchestrator.php +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -47,13 +47,21 @@ public function capture( $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { - throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'); + return [ + 'failure' => $snapshot['failure'], + ]; } $payload = $snapshot['payload']; $assignments = null; $scopeTags = null; - $captureMetadata = []; + $captureMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; + + $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; + if ($snapshotWarnings !== []) { + $existingWarnings = is_array($captureMetadata['warnings'] ?? null) ? $captureMetadata['warnings'] : []; + $captureMetadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); + } // 2. Fetch assignments if requested if ($includeAssignments) { @@ -179,9 +187,9 @@ public function capture( // 5. Create new PolicyVersion with all captured data $metadata = array_merge( - ['source' => 'orchestrated_capture'], + ['capture_source' => 'orchestrated_capture'], $metadata, - $captureMetadata + $captureMetadata, ); $version = $this->versionService->captureVersion( diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 3e6b82a..33572eb 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -8,6 +8,7 @@ use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphErrorMapper; use App\Services\Graph\GraphLogger; +use App\Services\Graph\GraphResponse; use Illuminate\Support\Arr; use Throwable; @@ -62,6 +63,11 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null } catch (Throwable $throwable) { $mapped = GraphErrorMapper::fromThrowable($throwable, $context); + // For certain policy types experiencing upstream Graph issues, fall back to metadata-only + if ($this->shouldFallbackToMetadata($policy->policy_type, $mapped->status)) { + return $this->createMetadataOnlySnapshot($policy, $mapped->getMessage(), $mapped->status); + } + return [ 'failure' => [ 'policy_id' => $policy->id, @@ -87,8 +93,9 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } - if ($policy->policy_type === 'settingsCatalogPolicy') { - [$payload, $metadata] = $this->hydrateSettingsCatalog( + if (in_array($policy->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) { + [$payload, $metadata] = $this->hydrateConfigurationPolicySettings( + policyType: $policy->policy_type, tenantIdentifier: $tenantIdentifier, tenant: $tenant, policyId: $policy->external_id, @@ -118,7 +125,12 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null } if ($response->failed()) { - $reason = $response->warnings[0] ?? 'Graph request failed'; + $reason = $this->formatGraphFailureReason($response); + + if ($this->shouldFallbackToMetadata($policy->policy_type, $response->status)) { + return $this->createMetadataOnlySnapshot($policy, $reason, $response->status); + } + $failure = [ 'policy_id' => $policy->id, 'reason' => $reason, @@ -162,6 +174,47 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ]; } + private function formatGraphFailureReason(GraphResponse $response): string + { + $code = $response->meta['error_code'] + ?? ($response->errors[0]['code'] ?? null) + ?? ($response->data['error']['code'] ?? null); + + $message = $response->meta['error_message'] + ?? ($response->errors[0]['message'] ?? null) + ?? ($response->data['error']['message'] ?? null) + ?? ($response->warnings[0] ?? null); + + $reason = 'Graph request failed'; + + if (is_string($message) && $message !== '') { + $reason = $message; + } + + if (is_string($code) && $code !== '') { + $reason = sprintf('%s: %s', $code, $reason); + } + + $requestId = $response->meta['request_id'] ?? null; + $clientRequestId = $response->meta['client_request_id'] ?? null; + + $suffixParts = []; + + if (is_string($clientRequestId) && $clientRequestId !== '') { + $suffixParts[] = sprintf('client_request_id=%s', $clientRequestId); + } + + if (is_string($requestId) && $requestId !== '') { + $suffixParts[] = sprintf('request_id=%s', $requestId); + } + + if ($suffixParts !== []) { + $reason = sprintf('%s (%s)', $reason, implode(', ', $suffixParts)); + } + + return $reason; + } + /** * Hydrate Windows Update Ring payload via derived type cast to capture * windowsUpdateForBusinessConfiguration-specific properties. @@ -263,14 +316,14 @@ private function filterMetadataOnlyPayload(string $policyType, array $payload): } /** - * Hydrate settings catalog policies with configuration settings subresource. + * Hydrate configurationPolicies settings via settings subresource (Settings Catalog / Endpoint Security / Baselines). * * @return array{0:array,1:array} */ - private function hydrateSettingsCatalog(string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array + private function hydrateConfigurationPolicySettings(string $policyType, string $tenantIdentifier, Tenant $tenant, string $policyId, array $payload, array $metadata): array { - $strategy = $this->contracts->memberHydrationStrategy('settingsCatalogPolicy'); - $settingsPath = $this->contracts->subresourceSettingsPath('settingsCatalogPolicy', $policyId); + $strategy = $this->contracts->memberHydrationStrategy($policyType); + $settingsPath = $this->contracts->subresourceSettingsPath($policyType, $policyId); if ($strategy !== 'subresource_settings' || ! $settingsPath) { return [$payload, $metadata]; @@ -592,6 +645,69 @@ private function stripGraphBaseUrl(string $nextLink): string return ltrim(substr($nextLink, strlen($base)), '/'); } - return ltrim($nextLink, '/'); + return $nextLink; + } + + /** + * Determine if we should fall back to metadata-only for this policy type and error. + */ + private function shouldFallbackToMetadata(string $policyType, ?int $status): bool + { + // Only fallback on 5xx server errors + if ($status === null || $status < 500 || $status >= 600) { + return false; + } + + // Enable fallback for policy types experiencing upstream Graph issues + $fallbackTypes = [ + 'mamAppConfiguration', + 'managedDeviceAppConfiguration', + ]; + + return in_array($policyType, $fallbackTypes, true); + } + + /** + * Create a metadata-only snapshot from the Policy model when Graph is unavailable. + * + * @return array{payload:array,metadata:array,warnings:array} + */ + private function createMetadataOnlySnapshot(Policy $policy, string $failureReason, ?int $status): array + { + $odataType = match ($policy->policy_type) { + 'mamAppConfiguration' => '#microsoft.graph.targetedManagedAppConfiguration', + 'managedDeviceAppConfiguration' => '#microsoft.graph.managedDeviceMobileAppConfiguration', + default => '#microsoft.graph.'.$policy->policy_type, + }; + + $payload = [ + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + '@odata.type' => $odataType, + 'createdDateTime' => $policy->created_at?->toIso8601String(), + 'lastModifiedDateTime' => $policy->updated_at?->toIso8601String(), + ]; + + if ($policy->platform) { + $payload['platform'] = $policy->platform; + } + + $metadata = [ + 'source' => 'metadata_only', + 'original_failure' => $failureReason, + 'original_status' => $status, + 'warnings' => [ + sprintf( + 'Snapshot captured from local metadata only (Graph API returned %s). Restore preview available, full restore not possible.', + $status ?? 'error' + ), + ], + ]; + + return [ + 'payload' => $payload, + 'metadata' => $metadata, + 'warnings' => $metadata['warnings'], + ]; } } diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index cf08815..1f87f19 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -25,6 +25,19 @@ public function __construct( * @return array IDs of policies synced or created */ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): array + { + $result = $this->syncPoliciesWithReport($tenant, $supportedTypes); + + return $result['synced']; + } + + /** + * Sync supported policies for a tenant from Microsoft Graph. + * + * @param array|null $supportedTypes + * @return array{synced: array, failures: array} + */ + public function syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array { if (! $tenant->isActive()) { throw new \RuntimeException('Tenant is archived or inactive.'); @@ -32,6 +45,7 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr $types = $supportedTypes ?? config('tenantpilot.supported_policy_types', []); $synced = []; + $failures = []; $tenantIdentifier = $tenant->tenant_id ?? $tenant->external_id; foreach ($types as $typeConfig) { @@ -69,6 +83,13 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr ]); if ($response->failed()) { + $failures[] = [ + 'policy_type' => $policyType, + 'status' => $response->status, + 'errors' => $response->errors, + 'meta' => $response->meta, + ]; + continue; } @@ -109,6 +130,12 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr policyType: $policyType, ); + $this->reclassifyConfigurationPoliciesIfNeeded( + tenantId: $tenant->id, + externalId: $externalId, + policyType: $policyType, + ); + $policy = Policy::updateOrCreate( [ 'tenant_id' => $tenant->id, @@ -128,11 +155,18 @@ public function syncPolicies(Tenant $tenant, ?array $supportedTypes = null): arr } } - return $synced; + return [ + 'synced' => $synced, + 'failures' => $failures, + ]; } private function resolveCanonicalPolicyType(string $policyType, array $policyData): string { + if (in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true)) { + return $this->resolveConfigurationPolicyType($policyData); + } + if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { return $policyType; } @@ -141,11 +175,75 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat return 'windowsEnrollmentStatusPage'; } - if ($this->isEnrollmentRestrictionItem($policyData)) { - return 'enrollmentRestriction'; + return 'enrollmentRestriction'; + } + + private function resolveConfigurationPolicyType(array $policyData): string + { + if ($this->isSecurityBaselineConfigurationPolicy($policyData)) { + return 'securityBaselinePolicy'; } - return $policyType; + if ($this->isEndpointSecurityConfigurationPolicy($policyData)) { + return 'endpointSecurityPolicy'; + } + + return 'settingsCatalogPolicy'; + } + + private function isEndpointSecurityConfigurationPolicy(array $policyData): bool + { + $technologies = $policyData['technologies'] ?? null; + + if (is_string($technologies)) { + if (strcasecmp(trim($technologies), 'endpointSecurity') === 0) { + return true; + } + } + + if (is_array($technologies)) { + foreach ($technologies as $technology) { + if (is_string($technology) && strcasecmp(trim($technology), 'endpointSecurity') === 0) { + return true; + } + } + } + + $templateReference = $policyData['templateReference'] ?? null; + + if (! is_array($templateReference)) { + return false; + } + + foreach ($templateReference as $value) { + if (is_string($value) && stripos($value, 'endpoint') !== false) { + return true; + } + } + + return false; + } + + private function isSecurityBaselineConfigurationPolicy(array $policyData): bool + { + $templateReference = $policyData['templateReference'] ?? null; + + if (! is_array($templateReference)) { + return false; + } + + $templateFamily = $templateReference['templateFamily'] ?? null; + if (is_string($templateFamily) && stripos($templateFamily, 'baseline') !== false) { + return true; + } + + foreach ($templateReference as $value) { + if (is_string($value) && stripos($value, 'baseline') !== false) { + return true; + } + } + + return false; } private function isEnrollmentStatusPageItem(array $policyData): bool @@ -157,33 +255,6 @@ private function isEnrollmentStatusPageItem(array $policyData): bool || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); } - private function isEnrollmentRestrictionItem(array $policyData): bool - { - $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; - $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; - - $restrictionOdataTypes = [ - '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', - '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', - '#microsoft.graph.deviceEnrollmentLimitConfiguration', - ]; - - if (is_string($odataType)) { - foreach ($restrictionOdataTypes as $expected) { - if (strcasecmp($odataType, $expected) === 0) { - return true; - } - } - } - - return is_string($configurationType) - && in_array($configurationType, [ - 'deviceEnrollmentPlatformRestrictionConfiguration', - 'deviceEnrollmentPlatformRestrictionsConfiguration', - 'deviceEnrollmentLimitConfiguration', - ], true); - } - private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void { if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { @@ -231,6 +302,53 @@ private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId ->update(['policy_type' => $policyType]); } + private function reclassifyConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + $configurationTypes = ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy']; + + if (! in_array($policyType, $configurationTypes, true)) { + return; + } + + $existingCorrect = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->where('policy_type', $policyType) + ->first(); + + if ($existingCorrect) { + Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $configurationTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->update(['ignored_at' => now()]); + + return; + } + + $existingWrong = Policy::query() + ->where('tenant_id', $tenantId) + ->where('external_id', $externalId) + ->whereIn('policy_type', $configurationTypes) + ->where('policy_type', '!=', $policyType) + ->whereNull('ignored_at') + ->first(); + + if (! $existingWrong) { + return; + } + + $existingWrong->forceFill([ + 'policy_type' => $policyType, + ])->save(); + + PolicyVersion::query() + ->where('policy_id', $existingWrong->id) + ->update(['policy_type' => $policyType]); + } + /** * Re-fetch a single policy from Graph and update local metadata. */ diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php index 96ff30c..35383a4 100644 --- a/app/Services/Intune/RestoreRiskChecker.php +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -38,6 +38,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem $results = []; $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); + $results[] = $this->checkMetadataOnlySnapshots($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems); @@ -228,6 +229,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array ]; } + /** + * Detect snapshots that were captured as metadata-only. + * + * These snapshots cannot be safely restored because they do not contain the + * complete settings payload. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkMetadataOnlySnapshots(Collection $policyItems): ?array + { + $affected = []; + $hasRestoreEnabled = false; + + foreach ($policyItems as $item) { + if (! $this->isMetadataOnlySnapshot($item)) { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $affected[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'policy_type' => $item->policy_type, + 'label' => $item->resolvedDisplayName(), + 'restore_mode' => $restoreMode, + ]; + } + + if ($affected === []) { + return [ + 'code' => 'metadata_only', + 'severity' => 'safe', + 'title' => 'Snapshot completeness', + 'message' => 'No metadata-only snapshots detected.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some selected items were captured as metadata-only. Restore cannot execute until Graph works again.' + : 'Some selected items were captured as metadata-only. Execution is preview-only, but payload completeness is limited.'; + + return [ + 'code' => 'metadata_only', + 'severity' => $severity, + 'title' => 'Snapshot completeness', + 'message' => $message, + 'meta' => [ + 'count' => count($affected), + 'items' => $this->truncateList($affected, 10), + ], + ]; + } + + private function isMetadataOnlySnapshot(BackupItem $item): bool + { + $metadata = is_array($item->metadata) ? $item->metadata : []; + + $source = $metadata['source'] ?? null; + $snapshotSource = $metadata['snapshot_source'] ?? null; + + if ($source === 'metadata_only' || $snapshotSource === 'metadata_only') { + return true; + } + + $warnings = $metadata['warnings'] ?? null; + if (is_array($warnings)) { + foreach ($warnings as $warning) { + if (is_string($warning) && Str::contains(Str::lower($warning), 'metadata only')) { + return true; + } + } + } + + return false; + } + /** * @param Collection $policyItems * @return array{code: string, severity: string, title: string, message: string, meta: array}|null diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 85f23fe..e75040a 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -151,6 +151,18 @@ public function executeFromPolicyVersion( 'version_captured_at' => $version->captured_at?->toIso8601String(), ]; + $versionMetadata = is_array($version->metadata) ? $version->metadata : []; + $snapshotSource = $versionMetadata['source'] ?? null; + + if (is_string($snapshotSource) && $snapshotSource !== '' && $snapshotSource !== 'policy_version') { + $backupItemMetadata['snapshot_source'] = $snapshotSource; + } + + $snapshotWarnings = $versionMetadata['warnings'] ?? null; + if (is_array($snapshotWarnings) && $snapshotWarnings !== []) { + $backupItemMetadata['warnings'] = array_values(array_unique(array_filter($snapshotWarnings, static fn ($value) => is_string($value) && $value !== ''))); + } + if (is_array($scopeTagIds) && $scopeTagIds !== []) { $backupItemMetadata['scope_tag_ids'] = $scopeTagIds; } diff --git a/app/Services/Intune/SettingsCatalogDefinitionResolver.php b/app/Services/Intune/SettingsCatalogDefinitionResolver.php index 208ffa2..8566be3 100644 --- a/app/Services/Intune/SettingsCatalogDefinitionResolver.php +++ b/app/Services/Intune/SettingsCatalogDefinitionResolver.php @@ -269,10 +269,49 @@ public function prettifyDefinitionId(string $definitionId): string // Remove {tenantid} placeholder - it's a Microsoft template variable, not part of the name $cleaned = str_replace(['{tenantid}', '_tenantid_', '_{tenantid}_'], ['', '_', '_'], $definitionId); + // Remove other template placeholders, e.g. "{FirewallRuleId}" + $cleaned = preg_replace('/\{[^}]+\}/', '', $cleaned); + // Clean up consecutive underscores $cleaned = preg_replace('/_+/', '_', $cleaned); $cleaned = trim($cleaned, '_'); + $lowered = Str::lower($cleaned); + + if (str_starts_with($lowered, 'vendor_msft_firewall_mdmstore_firewallrules')) { + $suffix = ltrim(substr($lowered, strlen('vendor_msft_firewall_mdmstore_firewallrules')), '_'); + + if ($suffix === '') { + return 'Firewall rule'; + } + + $known = [ + 'displayname' => 'Name', + 'name' => 'Name', + 'description' => 'Description', + 'direction' => 'Direction', + 'action' => 'Action', + 'actiontype' => 'Action type', + 'profiles' => 'Profiles', + 'profile' => 'Profile', + 'protocol' => 'Protocol', + 'localport' => 'Local port', + 'remoteport' => 'Remote port', + 'localaddress' => 'Local address', + 'remoteaddress' => 'Remote address', + 'interfacetype' => 'Interface type', + 'interfacetypes' => 'Interface types', + 'edgetraversal' => 'Edge traversal', + 'enabled' => 'Enabled', + ]; + + if (isset($known[$suffix])) { + return $known[$suffix]; + } + + return Str::headline($suffix); + } + // Convert to title case $prettified = Str::title(str_replace('_', ' ', $cleaned)); diff --git a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php index 1b74907..e42a7a2 100644 --- a/app/Services/Intune/SettingsCatalogPolicyNormalizer.php +++ b/app/Services/Intune/SettingsCatalogPolicyNormalizer.php @@ -10,7 +10,7 @@ public function __construct( public function supports(string $policyType): bool { - return $policyType === 'settingsCatalogPolicy'; + return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); } /** diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index f1f300e..01992ef 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -85,6 +85,8 @@ public function captureFromGraph( } $payload = $snapshot['payload']; + $snapshotMetadata = is_array($snapshot['metadata'] ?? null) ? $snapshot['metadata'] : []; + $snapshotWarnings = is_array($snapshot['warnings'] ?? null) ? $snapshot['warnings'] : []; $assignments = null; $scopeTags = null; $assignmentMetadata = []; @@ -141,11 +143,17 @@ public function captureFromGraph( } $metadata = array_merge( - ['source' => 'version_capture'], + $snapshotMetadata, + ['capture_source' => 'version_capture'], $metadata, - $assignmentMetadata + $assignmentMetadata, ); + if ($snapshotWarnings !== []) { + $existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : []; + $metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $snapshotWarnings))); + } + return $this->captureVersion( policy: $policy, payload: $payload, diff --git a/config/graph_contracts.php b/config/graph_contracts.php index eb9bd59..ed0536f 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -78,7 +78,7 @@ ], 'settingsCatalogPolicy' => [ 'resource' => 'deviceManagement/configurationPolicies', - 'allowed_select' => ['id', 'name', 'displayName', 'description', '@odata.type', 'version', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime'], + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'templateReference', 'roleScopeTagIds', 'lastModifiedDateTime'], 'allowed_expand' => ['settings'], 'type_family' => [ '#microsoft.graph.deviceManagementConfigurationPolicy', @@ -132,6 +132,76 @@ 'supports_scope_tags' => true, 'scope_tag_field' => 'roleScopeTagIds', ], + 'endpointSecurityPolicy' => [ + 'resource' => 'deviceManagement/configurationPolicies', + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'member_hydration_strategy' => 'subresource_settings', + 'subresources' => [ + 'settings' => [ + 'path' => 'deviceManagement/configurationPolicies/{id}/settings', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'securityBaselinePolicy' => [ + 'resource' => 'deviceManagement/configurationPolicies', + 'allowed_select' => ['id', 'name', 'description', '@odata.type', 'platforms', 'technologies', 'roleScopeTagIds', 'lastModifiedDateTime', 'templateReference'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'member_hydration_strategy' => 'subresource_settings', + 'subresources' => [ + 'settings' => [ + 'path' => 'deviceManagement/configurationPolicies/{id}/settings', + 'collection' => true, + 'paging' => true, + 'allowed_select' => [], + 'allowed_expand' => [], + ], + ], + + // Assignments CRUD (standard Graph pattern) + 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/configurationPolicies/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + + // Scope Tags + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'windowsUpdateRing' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], @@ -263,6 +333,43 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'assignments', ], + 'mamAppConfiguration' => [ + 'resource' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.targetedManagedAppConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/targetedManagedAppConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], + 'managedDeviceAppConfiguration' => [ + 'resource' => 'deviceAppManagement/mobileAppConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'createdDateTime', 'lastModifiedDateTime', 'roleScopeTagIds'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.managedDeviceMobileAppConfiguration', + '#microsoft.graph.mobileAppConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceAppManagement/mobileAppConfigurations/{id}/microsoft.graph.managedDeviceMobileAppConfiguration/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'assignments', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'conditionalAccessPolicy' => [ 'resource' => 'identity/conditionalAccess/policies', 'allowed_select' => ['id', 'displayName', 'state', 'createdDateTime', 'modifiedDateTime', '@odata.type'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 7ec3820..e3d1ec9 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -84,6 +84,27 @@ 'restore' => 'enabled', 'risk' => 'medium-high', ], + [ + 'type' => 'mamAppConfiguration', + 'label' => 'App Configuration (MAM)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], + [ + 'type' => 'managedDeviceAppConfiguration', + 'label' => 'App Configuration (Device)', + 'category' => 'Apps/MAM', + 'platform' => 'mobile', + 'endpoint' => 'deviceAppManagement/mobileAppConfigurations', + 'filter' => "microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false", + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'conditionalAccessPolicy', 'label' => 'Conditional Access', @@ -140,7 +161,6 @@ 'category' => 'Enrollment', 'platform' => 'all', 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', - 'filter' => "isof('microsoft.graph.windows10EnrollmentCompletionPageConfiguration')", 'backup' => 'full', 'restore' => 'enabled', 'risk' => 'medium', @@ -165,6 +185,26 @@ 'restore' => 'enabled', 'risk' => 'high', ], + [ + 'type' => 'endpointSecurityPolicy', + 'label' => 'Endpoint Security Policies', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/configurationPolicies', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'securityBaselinePolicy', + 'label' => 'Security Baselines', + 'category' => 'Endpoint Security', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/configurationPolicies', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'mobileApp', 'label' => 'Applications (Metadata only)', diff --git a/resources/views/filament/infolists/entries/policy-general.blade.php b/resources/views/filament/infolists/entries/policy-general.blade.php index 69bd5f8..c22bc0a 100644 --- a/resources/views/filament/infolists/entries/policy-general.blade.php +++ b/resources/views/filament/infolists/entries/policy-general.blade.php @@ -1,4 +1,7 @@ @php + use Carbon\CarbonImmutable; + use Illuminate\Support\Str; + $general = $getState(); $entries = is_array($general) ? ($general['entries'] ?? []) : []; $cards = []; @@ -61,6 +64,27 @@ 'teal' => 'bg-teal-100/80 text-teal-700 dark:bg-teal-900/40 dark:text-teal-200', 'slate' => 'bg-slate-100/80 text-slate-700 dark:bg-slate-900/40 dark:text-slate-200', ]; + + $formatIsoDateTime = static function (string $value): ?string { + $trimmed = trim($value); + + if ($trimmed === '') { + return null; + } + + if (! preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/', $trimmed)) { + return null; + } + + // Graph can return 7 fractional digits; PHP supports 6 (microseconds). + $normalized = preg_replace('/\.(\d{6})\d+Z$/', '.$1Z', $trimmed); + + try { + return CarbonImmutable::parse($normalized)->toDateTimeString(); + } catch (\Throwable) { + return null; + } + }; @endphp @if (empty($cards)) @@ -72,6 +96,9 @@ $keyLower = $entry['key_lower'] ?? ''; $value = $entry['value'] ?? null; $isPlatform = str_contains($keyLower, 'platform'); + $isTechnologies = str_contains($keyLower, 'technolog'); + $isTemplateReference = str_contains($keyLower, 'template'); + $isDateTime = is_string($value) && ($formattedDateTime = $formatIsoDateTime($value)) !== null; $toneKey = match (true) { str_contains($keyLower, 'name') => 'name', str_contains($keyLower, 'platform') => 'platform', @@ -88,6 +115,15 @@ $isBooleanValue = is_bool($value); $isBooleanString = is_string($value) && in_array(strtolower($value), ['true', 'false', 'enabled', 'disabled'], true); $isNumericValue = is_numeric($value); + + $badgeItems = null; + + if ($isListValue) { + $badgeItems = $value; + } elseif (($isPlatform || $isTechnologies) && is_string($value)) { + $split = array_values(array_filter(array_map('trim', explode(',', $value)), static fn (string $item): bool => $item !== '')); + $badgeItems = $split !== [] ? $split : [$value]; + } @endphp
@@ -100,16 +136,50 @@ {{ $entry['key'] ?? '-' }}
- @if ($isListValue) + @if ($isTemplateReference && is_array($value)) + @php + $templateDisplayName = $value['templateDisplayName'] ?? null; + $templateFamily = $value['templateFamily'] ?? null; + $templateDisplayVersion = $value['templateDisplayVersion'] ?? null; + $templateId = $value['templateId'] ?? null; + + $familyLabel = is_string($templateFamily) && $templateFamily !== '' ? Str::headline($templateFamily) : null; + @endphp + +
+
+ {{ is_string($templateDisplayName) && $templateDisplayName !== '' ? $templateDisplayName : 'Template' }} +
+ +
+ @if ($familyLabel) + {{ $familyLabel }} + @endif + @if (is_string($templateDisplayVersion) && $templateDisplayVersion !== '') + {{ $templateDisplayVersion }} + @endif +
+ + @if (is_string($templateId) && $templateId !== '') +
+ {{ $templateId }} +
+ @endif +
+ @elseif ($isDateTime) +
+ {{ $formattedDateTime }} +
+ @elseif (is_array($badgeItems) && $badgeItems !== [])
- @foreach ($value as $item) + @foreach ($badgeItems as $item) {{ $item }} @endforeach
@elseif ($isJsonValue) -
{{ json_encode($value, JSON_PRETTY_PRINT) }}
+
{{ json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
@elseif ($isBooleanValue || $isBooleanString) @php $boolValue = $isBooleanValue @@ -126,7 +196,7 @@
@else
- {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT) }} + {{ is_string($value) ? $value : json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) }}
@endif diff --git a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php index f644e9f..2707044 100644 --- a/resources/views/filament/infolists/entries/policy-settings-standard.blade.php +++ b/resources/views/filament/infolists/entries/policy-settings-standard.blade.php @@ -59,6 +59,24 @@ return true; }; + + $asEnabledDisabledBadgeValue = function (mixed $value): ?bool { + if (is_bool($value)) { + return $value; + } + + if (! is_string($value)) { + return null; + } + + $normalized = strtolower(trim($value)); + + return match ($normalized) { + 'enabled', 'true', 'yes', '1' => true, + 'disabled', 'false', 'no', '0' => false, + default => null, + }; + }; @endphp
@@ -98,9 +116,13 @@
- @if(is_bool($row['value'])) - - {{ $row['value'] ? 'Enabled' : 'Disabled' }} + @php + $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null); + @endphp + + @if(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} @elseif(is_numeric($row['value'])) {{ $row['value'] }} @@ -135,16 +157,20 @@
@foreach($block['rows'] ?? [] as $row)
-
+
{{ $row['label'] ?? $row['path'] ?? 'Setting' }} @if(!empty($row['description']))

{{ Str::limit($row['description'], 80) }}

@endif
- @if(is_bool($row['value'])) - - {{ $row['value'] ? 'Enabled' : 'Disabled' }} + @php + $badgeValue = $asEnabledDisabledBadgeValue($row['value'] ?? null); + @endphp + + @if(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} @elseif(is_numeric($row['value'])) @@ -192,6 +218,8 @@ $isScriptContent = in_array($entry['key'] ?? null, ['scriptContent', 'detectionScriptContent', 'remediationScriptContent'], true) && (bool) config('tenantpilot.display.show_script_content', false); + + $badgeValue = $asEnabledDisabledBadgeValue($rawValue); @endphp @if($isScriptContent) @@ -276,6 +304,10 @@ @endforeach
+ @elseif(! is_null($badgeValue)) + + {{ $badgeValue ? 'Enabled' : 'Disabled' }} + @else {{ Str::limit($stringifyValue($rawValue), 200) }} diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md b/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md new file mode 100644 index 0000000..90a1936 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/checklists/requirements.md @@ -0,0 +1,7 @@ +# Requirements Checklist (017) + +- [x] Type keys and Graph resources confirmed for App Config Policies. +- [x] Type keys and Graph resources confirmed for Endpoint Security Policies. +- [x] Type keys and Graph resources confirmed for Security Baselines. +- [x] Restore mode decisions documented (enabled vs preview-only) per type. +- [x] Tests planned for sync + backup + preview. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/plan.md b/specs/017-policy-types-mam-endpoint-security-baselines/plan.md new file mode 100644 index 0000000..0f4d942 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/plan.md @@ -0,0 +1,41 @@ +# Plan: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md) + +## Approach +1. Inventory current supported types (config + graph contracts) and identify gaps. +2. Define new type keys and metadata in `config/tenantpilot.php`. +3. Add graph contracts in `config/graph_contracts.php` (resource, assigns, scope tags, create/update methods). +4. Extend snapshot/capture and restore services as needed (special casing only when required). +5. Add tests for: sync listing + backup capture + restore preview entry. + +## Decisions + +### Type keys + Graph resources +- `mamAppConfiguration` (MAM App Config) + - Graph collection: `deviceAppManagement/targetedManagedAppConfigurations` + - Primary `@odata.type`: `#microsoft.graph.targetedManagedAppConfiguration` +- `endpointSecurityPolicy` (Endpoint Security Policies) + - Graph collection: `deviceManagement/configurationPolicies` + - Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy` + - Classification: configuration policies where the snapshot indicates Endpoint Security via `technologies` and/or `templateReference`. +- `securityBaselinePolicy` (Security Baselines) + - Graph collection: `deviceManagement/configurationPolicies` + - Primary `@odata.type`: `#microsoft.graph.deviceManagementConfigurationPolicy` + - Classification: configuration policies where the snapshot indicates a baseline via `templateReference` (template family/type). + +### Restore modes +- `mamAppConfiguration`: `enabled` (risk: medium-high) +- `endpointSecurityPolicy`: `preview-only` (risk: high) +- `securityBaselinePolicy`: `preview-only` (risk: high) + +### Test plan +- Sync: new types show up with correct labels and do not leak into `settingsCatalogPolicy` / `appProtectionPolicy`. +- Backup: items created and snapshots captured for each new type. +- Restore: at minimum, restore preview produces entries; execution remains blocked for preview-only types. + +## Notes +- Default restore mode for security-sensitive types should be conservative (preview-only) unless we already have safe restore semantics. +- Prefer using existing generic graph-contract-driven code paths. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/spec.md b/specs/017-policy-types-mam-endpoint-security-baselines/spec.md new file mode 100644 index 0000000..293037a --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/spec.md @@ -0,0 +1,47 @@ +# Feature Specification: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Feature Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Created**: 2026-01-02 +**Status**: Draft + +## User Scenarios & Testing + +### User Story 1 — MAM App Config backup & restore (Priority: P1) +As an admin, I want Managed App Configuration policies (App Config) to be inventoried, backed up, and restorable, so I can safely manage MAM configurations (Outlook, Teams, Edge, OneDrive, etc.) at scale. + +This includes both: +- App configuration (app-targeted) via `deviceAppManagement/targetedManagedAppConfigurations` +- App configuration (managed device) via `deviceAppManagement/mobileAppConfigurations` + +**Acceptance Scenarios** +1. Given a tenant with App Config policies, when I sync policies, then I can see them in the policy inventory with correct type labels. +2. Given a policy, when I add it to a backup set, then it is captured and a backup item is created. +3. Given a backup item, when I start a restore preview, then I can see a safe preview of changes. + +### User Story 2 — Endpoint Security policies (not only intents) (Priority: P1) +As an admin, I want Endpoint Security policies (Firewall/Defender/ASR/BitLocker etc.) supported, so the Windows security core can be backed up and restored. + +**Acceptance Scenarios** +1. Given Endpoint Security policies exist, sync shows them as their own policy type. +2. Backup captures them successfully. + +### User Story 3 — Security baselines (Priority: P1) +As an admin, I want Security Baselines supported because they are commonly used and are expected in a complete solution. + +**Acceptance Scenarios** +1. Given baseline policies exist, sync shows them. +2. Backup captures them. + +## Requirements + +### Functional Requirements +- **FR-001**: Add support for Managed App Configuration policies. +- **FR-002**: Add support for Endpoint Security policies beyond intents. +- **FR-003**: Add support for Security Baselines. +- **FR-004**: Each new type must integrate with: inventory, backup, restore preview, and (where safe) restore execution. +- **FR-005**: Changes must be covered by automated tests. + +## Success Criteria +- **SC-001**: New policy types appear in inventory & picker. +- **SC-002**: Backup/restore preview works for new types. +- **SC-003**: No regressions in existing policy flows. diff --git a/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md b/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md new file mode 100644 index 0000000..a031c80 --- /dev/null +++ b/specs/017-policy-types-mam-endpoint-security-baselines/tasks.md @@ -0,0 +1,56 @@ +# Tasks: Policy Types (MAM App Config + Endpoint Security Policies + Security Baselines) (017) + +**Branch**: `feat/017-policy-types-mam-endpoint-security-baselines` +**Date**: 2026-01-02 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Inventory & Design +- [x] T002 Inventory existing policy types and identify missing graph resources. +- [x] T003 Decide type keys + restore modes for: app config, endpoint security policies, security baselines. + +## Phase 3: Tests (TDD) +- [x] T004 Add tests for policy sync listing new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`). +- [x] T005 Add tests for backup capture creating backup items for new types (`mamAppConfiguration`, `endpointSecurityPolicy`, `securityBaselinePolicy`). +- [x] T006 Add tests for restore preview for new types (at least preview-only for `endpointSecurityPolicy`, `securityBaselinePolicy`). + +## Phase 4: Implementation +- [x] T007 Add new types to `config/tenantpilot.php`. +- [x] T008 Add new graph contracts to `config/graph_contracts.php`. +- [x] T009 Implement any required snapshot/capture/restore handling. + +## Phase 4b: Follow-up (MAM Device App Config) +- [x] T012 Add managed device app configurations (`mobileAppConfigurations`) to supported types + graph contracts + sync test. + +## Phase 5: Verification +- [x] T010 Run targeted tests. +- [x] T011 Run Pint (`./vendor/bin/pint --dirty`). + +## Phase 5b: UI Polish +- [x] T013 Render Enabled/Disabled-like string values as badges in settings views for consistent UI. + +## Phase 4c: Bugfix +- [x] T014 Ensure configuration policy list sync selects `technologies`/`templateReference` so Endpoint Security + Baselines can be classified. + +## Phase 4d: UX Debuggability +- [x] T015 Show per-type sync failures in Policy sync UI so 0-synced cases are actionable. + +## Phase 4e: Bugfix (Graph OData) +- [x] T016 Fix configuration policy list sync `$select` to avoid unsupported `version` field (Graph 400). + +## Phase 4f: Bugfix (Enrollment OData) +- [x] T017 Fix ESP (`windowsEnrollmentStatusPage`) sync filter to avoid Graph 400 "Invalid filter PropertyName". + +## Phase 4g: Bugfix (Endpoint Security Classification) +- [x] T018 Fix endpoint security configuration policies being misclassified as settings catalog when `technologies=mdm`. + +## Phase 4h: Bugfix (Graph Pagination) +- [x] T019 Paginate Graph list responses so Endpoint Security policies on page 2+ are synced. + +## Phase 4i: Feature (Endpoint Security Settings Display) +- [x] T020 Hydrate `configurationPolicies/{id}/settings` for `endpointSecurityPolicy` + `securityBaselinePolicy` snapshots. +- [x] T021 Render Endpoint Security + Baselines via Settings Catalog normalizer/table (diff + UI). +- [x] T022 Prettify Endpoint Security template settings (use `templateReference.templateDisplayName` as fallback category + nicer Firewall rule labels/values). +- [x] T023 Improve Policy General tab cards (template reference summary, badges, readable timestamps). diff --git a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php index e207423..a1a9143 100644 --- a/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php +++ b/tests/Feature/Filament/BackupSetPolicyPickerTableTest.php @@ -53,6 +53,105 @@ ]) ->callTableBulkAction('add_selected_to_backup_set', $policies) ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('success'); +}); + +test('policy picker table does not warn if failures already existed but did not increase', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'status' => 'partial', + 'metadata' => [ + 'failures' => [ + ['policy_id' => 1, 'reason' => 'Previous failure', 'status' => 500], + ], + ], + ]); + + $policies = Policy::factory()->count(1)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->andReturn($backupSet); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('success'); +}); + +test('policy picker table warns when new failures were added', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Test backup', + 'status' => 'completed', + 'metadata' => ['failures' => []], + ]); + + $policies = Policy::factory()->count(1)->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + 'last_synced_at' => now(), + ]); + + $this->mock(BackupService::class, function (MockInterface $mock) use ($backupSet) { + $mock->shouldReceive('addPoliciesToSet') + ->once() + ->andReturnUsing(function () use ($backupSet) { + $backupSet->update([ + 'status' => 'partial', + 'metadata' => [ + 'failures' => [ + ['policy_id' => 123, 'reason' => 'New failure', 'status' => 500], + ], + ], + ]); + + return $backupSet->refresh(); + }); + }); + + Livewire::actingAs($user) + ->test(BackupSetPolicyPickerTable::class, [ + 'backupSetId' => $backupSet->id, + ]) + ->callTableBulkAction('add_selected_to_backup_set', $policies) + ->assertHasNoTableBulkActionErrors(); + + $notifications = session('filament.notifications', []); + + expect($notifications)->not->toBeEmpty(); + expect(collect($notifications)->last()['title'] ?? null)->toBe('Backup items added with failures'); + expect(collect($notifications)->last()['status'] ?? null)->toBe('warning'); }); test('policy picker table can filter by has versions', function () { diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index 2505927..a83f411 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -11,7 +11,7 @@ uses(RefreshDatabase::class); -test('settings catalog policies render a normalized settings table', function () { +test('configuration policy types render a normalized settings table', function (string $policyType) { $tenant = Tenant::create([ 'tenant_id' => 'local-tenant', 'name' => 'Tenant One', @@ -24,7 +24,7 @@ $policy = Policy::create([ 'tenant_id' => $tenant->id, 'external_id' => 'scp-policy-1', - 'policy_type' => 'settingsCatalogPolicy', + 'policy_type' => $policyType, 'display_name' => 'Settings Catalog Policy', 'platform' => 'windows', ]); @@ -33,7 +33,7 @@ 'tenant_id' => $tenant->id, 'policy_id' => $policy->id, 'version_number' => 1, - 'policy_type' => $policy->policy_type, + 'policy_type' => $policyType, 'platform' => $policy->platform, 'created_by' => 'tester@example.com', 'captured_at' => CarbonImmutable::now(), @@ -116,4 +116,8 @@ preg_match('/]*data-block="general"[^>]*>.*?<\/section>/is', $versionResponse->getContent(), $versionGeneralSection); expect($versionGeneralSection)->not->toBeEmpty(); expect($versionGeneralSection[0])->toContain('x-cloak'); -}); +})->with([ + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); diff --git a/tests/Feature/PolicyGeneralViewTest.php b/tests/Feature/PolicyGeneralViewTest.php new file mode 100644 index 0000000..db59d92 --- /dev/null +++ b/tests/Feature/PolicyGeneralViewTest.php @@ -0,0 +1,41 @@ + fn (): array => [ + 'entries' => [ + ['key' => 'Name', 'value' => 'WindowsFirewall Endpointsecurity'], + ['key' => 'Platforms', 'value' => 'windows10'], + ['key' => 'Technologies', 'value' => 'mdm,microsoftSense'], + ['key' => 'Template Reference', 'value' => [ + 'templateId' => '19c8aa67-f286-4861-9aa0-f23541d31680_1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ]], + ['key' => 'Last Modified', 'value' => '2026-01-03T00:52:32.2784312Z'], + ], + ], + ], + )->render(); + + expect($html)->toContain('Windows Firewall Rules'); + expect($html)->toContain('Endpoint Security Firewall'); + expect($html)->toContain('Version 1'); + expect($html)->toContain('19c8aa67-f286-4861-9aa0-f23541d31680_1'); + + expect($html)->toContain('mdm'); + expect($html)->toContain('microsoftSense'); + expect($html)->toContain('fi-badge'); + + expect($html)->toContain('2026-01-03 00:52:32'); + expect($html)->not->toContain('T00:52:32.2784312Z'); + + expect($html)->not->toContain('"templateId"'); +}); diff --git a/tests/Feature/PolicySettingsStandardViewTest.php b/tests/Feature/PolicySettingsStandardViewTest.php new file mode 100644 index 0000000..1350e1c --- /dev/null +++ b/tests/Feature/PolicySettingsStandardViewTest.php @@ -0,0 +1,30 @@ + fn (): array => [ + 'settings' => [ + [ + 'type' => 'table', + 'title' => 'App configuration settings', + 'rows' => [ + ['label' => 'StringEnabled', 'value' => 'Enabled'], + ['label' => 'StringDisabled', 'value' => 'Disabled'], + ], + ], + ], + 'policy_type' => 'managedDeviceAppConfiguration', + ], + ], + )->render(); + + expect($html)->toContain('Enabled') + ->and($html)->toContain('Disabled') + ->and($html)->toContain('fi-badge'); +}); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php index a311e43..d296e33 100644 --- a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -71,3 +71,77 @@ expect($wrong->policy_type)->toBe('windowsEnrollmentStatusPage'); }); + +test('policy sync classifies ESP items without relying on Graph isof filter', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-sync-esp-no-filter', + 'name' => 'Tenant Sync ESP No Filter', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $payload = [ + [ + 'id' => 'esp-1', + 'displayName' => 'Enrollment Status Page', + '@odata.type' => '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration', + 'deviceEnrollmentConfigurationType' => 'windows10EnrollmentCompletionPageConfiguration', + ], + [ + 'id' => 'restriction-1', + 'displayName' => 'Default Enrollment Restriction', + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', + ], + [ + 'id' => 'other-1', + 'displayName' => 'Other Enrollment Config', + '@odata.type' => '#microsoft.graph.someOtherEnrollmentConfiguration', + 'deviceEnrollmentConfigurationType' => 'someOtherEnrollmentConfiguration', + ], + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($payload) { + if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + return new GraphResponse(true, $payload); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + [ + 'type' => 'windowsEnrollmentStatusPage', + 'platform' => 'all', + 'filter' => null, + ], + [ + 'type' => 'enrollmentRestriction', + 'platform' => 'all', + 'filter' => null, + ], + ]); + + $espIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'windowsEnrollmentStatusPage') + ->pluck('external_id') + ->all(); + + $restrictionIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'enrollmentRestriction') + ->orderBy('external_id') + ->pluck('external_id') + ->all(); + + expect($espIds)->toMatchArray(['esp-1']); + expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']); +}); diff --git a/tests/Feature/PolicySyncServiceReportTest.php b/tests/Feature/PolicySyncServiceReportTest.php new file mode 100644 index 0000000..0c53c60 --- /dev/null +++ b/tests/Feature/PolicySyncServiceReportTest.php @@ -0,0 +1,61 @@ +create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) { + return match ($policyType) { + 'endpointSecurityPolicy' => new GraphResponse( + success: false, + data: [], + status: 403, + errors: [['message' => 'Forbidden']], + meta: ['path' => '/deviceManagement/configurationPolicies'], + ), + default => new GraphResponse( + success: true, + data: [ + ['id' => 'scp-1', 'displayName' => 'Settings Catalog', 'technologies' => ['mdm']], + ], + status: 200, + ), + }; + }); + + $service = app(PolicySyncService::class); + + $result = $service->syncPoliciesWithReport($tenant, [ + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ]); + + expect($result)->toHaveKeys(['synced', 'failures']); + expect($result['synced'])->toBeArray(); + expect($result['failures'])->toBeArray(); + + expect(count($result['synced']))->toBe(1); + expect(Policy::query()->where('tenant_id', $tenant->id)->count())->toBe(1); + + expect(count($result['failures']))->toBe(1); + expect($result['failures'][0]['policy_type'])->toBe('endpointSecurityPolicy'); + expect($result['failures'][0]['status'])->toBe(403); +}); diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php index ef6d674..60beb81 100644 --- a/tests/Feature/PolicySyncServiceTest.php +++ b/tests/Feature/PolicySyncServiceTest.php @@ -1,6 +1,7 @@ toBe('deviceManagement/windowsQualityUpdateProfiles'); }); + +it('includes managed device app configurations in supported types', function () { + $supported = config('tenantpilot.supported_policy_types'); + $byType = collect($supported)->keyBy('type'); + + expect($byType)->toHaveKey('managedDeviceAppConfiguration'); + expect($byType['managedDeviceAppConfiguration']['endpoint'] ?? null) + ->toBe('deviceAppManagement/mobileAppConfigurations'); + expect($byType['managedDeviceAppConfiguration']['filter'] ?? null) + ->toBe("microsoft.graph.androidManagedStoreAppConfiguration/appSupportsOemConfig eq false or isof('microsoft.graph.androidManagedStoreAppConfiguration') eq false"); +}); + +it('syncs managed device app configurations from Graph', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('managedDeviceAppConfiguration', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'madc-1', + 'displayName' => 'MAM Device Config', + '@odata.type' => '#microsoft.graph.managedDeviceMobileAppConfiguration', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'managedDeviceAppConfiguration', 'platform' => 'mobile'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'managedDeviceAppConfiguration')->count()) + ->toBe(1); +}); + +it('classifies configuration policies into settings catalog, endpoint security, and security baseline types', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $graphResponse = new GraphResponse( + success: true, + data: [ + [ + 'id' => 'scp-1', + 'name' => 'Settings Catalog Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => null, + ], + [ + 'id' => 'esp-1', + 'name' => 'Endpoint Security Beta', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => 'mdm', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityDiskEncryption', + 'templateDisplayName' => 'BitLocker', + ], + ], + [ + 'id' => 'sb-1', + 'name' => 'Security Baseline Gamma', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'templateReference' => [ + 'templateFamily' => 'securityBaseline', + ], + ], + ], + ); + + $calledTypes = []; + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->times(3) + ->andReturnUsing(function (string $policyType) use (&$calledTypes, $graphResponse) { + $calledTypes[] = $policyType; + + return $graphResponse; + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'securityBaselinePolicy', 'platform' => 'windows'], + ]); + + expect($calledTypes)->toMatchArray([ + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy', + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'settingsCatalogPolicy')->count()) + ->toBe(1); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'endpointSecurityPolicy')->count()) + ->toBe(1); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'securityBaselinePolicy')->count()) + ->toBe(1); +}); + +it('reclassifies configuration policies when canonical type changes', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + 'display_name' => 'Misclassified', + 'ignored_at' => null, + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $graphResponse = new GraphResponse( + success: true, + data: [ + [ + 'id' => 'esp-1', + 'name' => 'Endpoint Security Beta', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => 'mdm', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityDiskEncryption', + 'templateDisplayName' => 'BitLocker', + ], + ], + ], + ); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->times(3) + ->andReturn($graphResponse); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'settingsCatalogPolicy', 'platform' => 'windows'], + ['type' => 'endpointSecurityPolicy', 'platform' => 'windows'], + ['type' => 'securityBaselinePolicy', 'platform' => 'windows'], + ]); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->whereNull('ignored_at') + ->count())->toBe(1); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->where('policy_type', 'endpointSecurityPolicy') + ->whereNull('ignored_at') + ->count())->toBe(1); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('external_id', 'esp-1') + ->where('policy_type', 'settingsCatalogPolicy') + ->whereNull('ignored_at') + ->count())->toBe(0); + + $version->refresh(); + + expect($version->policy_type)->toBe('endpointSecurityPolicy'); +}); diff --git a/tests/Feature/PolicyTypes017Test.php b/tests/Feature/PolicyTypes017Test.php new file mode 100644 index 0000000..3c3813d --- /dev/null +++ b/tests/Feature/PolicyTypes017Test.php @@ -0,0 +1,267 @@ +}> */ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'listPolicies', 'policyType' => $policyType, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + $payload = match ($policyType) { + 'mamAppConfiguration' => [ + 'id' => $policyId, + 'displayName' => 'MAM App Config', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + 'roleScopeTagIds' => ['0'], + ], + 'endpointSecurityPolicy' => [ + 'id' => $policyId, + 'name' => 'Endpoint Security Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + 'roleScopeTagIds' => ['0'], + ], + 'securityBaselinePolicy' => [ + 'id' => $policyId, + 'name' => 'Security Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + 'roleScopeTagIds' => ['0'], + ], + default => [ + 'id' => $policyId, + 'name' => 'Settings Catalog Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'roleScopeTagIds' => ['0'], + ], + }; + + return new GraphResponse(success: true, data: ['payload' => $payload]); + } + + public function getOrganization(array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getOrganization', 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'applyPolicy', 'policyType' => $policyType, 'policyId' => $policyId, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'getServicePrincipalPermissions', 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = ['method' => 'request', 'path' => $path, 'options' => $options]; + + return new GraphResponse(success: true, data: []); + } +} + +it('creates backup items for the new 017 policy types', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $mam = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'mam-1', + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + ]); + + $esp = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + ]); + + $sb = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'sb-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + ]); + + $this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('capture') + ->times(3) + ->andReturnUsing(function (Policy $policy) use ($tenant) { + $snapshot = match ($policy->policy_type) { + 'mamAppConfiguration' => [ + 'id' => $policy->external_id, + 'displayName' => 'MAM App Config', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + 'roleScopeTagIds' => ['0'], + ], + 'endpointSecurityPolicy' => [ + 'id' => $policy->external_id, + 'name' => 'Endpoint Security Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + 'roleScopeTagIds' => ['0'], + ], + 'securityBaselinePolicy' => [ + 'id' => $policy->external_id, + 'name' => 'Security Baseline Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + 'roleScopeTagIds' => ['0'], + ], + default => [ + 'id' => $policy->external_id, + 'name' => 'Settings Catalog Policy', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['mdm'], + 'roleScopeTagIds' => ['0'], + ], + }; + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'snapshot' => $snapshot, + 'assignments' => null, + 'scope_tags' => null, + ]); + + return [ + 'version' => $version, + 'captured' => [ + 'payload' => $snapshot, + 'assignments' => null, + 'scope_tags' => null, + 'metadata' => [], + 'warnings' => [], + ], + ]; + }); + }); + + $service = app(BackupService::class); + $backupSet = $service->createBackupSet( + tenant: $tenant, + policyIds: [$mam->id, $esp->id, $sb->id], + actorEmail: $user->email, + actorName: $user->name, + name: '017 backup', + includeAssignments: false, + includeScopeTags: false, + includeFoundations: false, + ); + + expect($backupSet->items)->toHaveCount(3); + + $types = $backupSet->items->pluck('policy_type')->all(); + sort($types); + + expect($types)->toBe([ + 'endpointSecurityPolicy', + 'mamAppConfiguration', + 'securityBaselinePolicy', + ]); + + expect(BackupItem::query()->where('backup_set_id', $backupSet->id)->count()) + ->toBe(3); +}); + +it('uses configured restore modes in preview for the new 017 policy types', function () { + $this->mock(GraphClientInterface::class); + + $tenant = Tenant::factory()->create(); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 3, + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'mam-1', + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + 'payload' => [ + 'id' => 'mam-1', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'technologies' => ['endpointSecurity'], + ], + ]); + + BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => null, + 'policy_identifier' => 'sb-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'sb-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => ['templateFamily' => 'securityBaseline'], + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet); + + $byType = collect($preview)->keyBy('policy_type'); + + expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled'); + expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only'); + expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only'); +}); diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index c878d84..1fe987b 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -220,3 +220,76 @@ expect($skippedGroups)->toBeArray(); expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1'); }); + +test('restore wizard flags metadata-only snapshots as blocking for restore-enabled types', 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' => 'mamAppConfiguration', + 'display_name' => 'MAM App Config', + 'platform' => 'mobile', + ]); + + $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, 'displayName' => $policy->display_name], + 'assignments' => [], + 'metadata' => [ + 'source' => 'metadata_only', + 'warnings' => [ + 'Graph returned 500 for this policy type. Only local metadata was saved; settings and restore are unavailable until Graph works again.', + ], + ], + ]); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->andReturn([]); + }); + + $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() + ->callFormComponentAction('check_results', 'run_restore_checks'); + + $summary = $component->get('data.check_summary'); + $results = $component->get('data.check_results'); + + expect($summary['blocking'] ?? null)->toBe(1); + expect($summary['has_blockers'] ?? null)->toBeTrue(); + + $metadataOnly = collect($results)->firstWhere('code', 'metadata_only'); + expect($metadataOnly)->toBeArray(); + expect($metadataOnly['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/VersionCaptureMetadataOnlyTest.php b/tests/Feature/VersionCaptureMetadataOnlyTest.php new file mode 100644 index 0000000..6560ab7 --- /dev/null +++ b/tests/Feature/VersionCaptureMetadataOnlyTest.php @@ -0,0 +1,66 @@ +create(); + $policy = Policy::factory()->for($tenant)->create([ + 'policy_type' => 'mamAppConfiguration', + 'platform' => 'mobile', + 'external_id' => 'A_meta_only', + 'display_name' => 'MAM Config Meta', + ]); + + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'A_meta_only', + 'displayName' => 'MAM Config Meta', + '@odata.type' => '#microsoft.graph.targetedManagedAppConfiguration', + ], + 'metadata' => [ + 'source' => 'metadata_only', + 'original_status' => 500, + 'original_failure' => 'InternalServerError: upstream', + ], + 'warnings' => [ + 'Snapshot captured from local metadata only (Graph API returned 500).', + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch')->never(); + }); + + $this->mock(ScopeTagResolver::class, function ($mock) { + $mock->shouldReceive('resolve')->never(); + }); + + $service = app(VersionService::class); + + $version = $service->captureFromGraph( + tenant: $tenant, + policy: $policy, + createdBy: 'tester@example.test', + includeAssignments: false, + includeScopeTags: false, + ); + + expect($version->metadata['source'])->toBe('metadata_only'); + expect($version->metadata['original_status'])->toBe(500); + expect($version->metadata['original_failure'])->toContain('InternalServerError'); + expect($version->metadata['capture_source'])->toBe('version_capture'); + expect($version->metadata['warnings'])->toBeArray(); + expect($version->metadata['warnings'][0])->toContain('metadata only'); +}); diff --git a/tests/Unit/GraphClientEndpointResolutionTest.php b/tests/Unit/GraphClientEndpointResolutionTest.php new file mode 100644 index 0000000..3c76a8a --- /dev/null +++ b/tests/Unit/GraphClientEndpointResolutionTest.php @@ -0,0 +1,63 @@ +set('graph.base_url', 'https://graph.microsoft.com'); + config()->set('graph.version', 'beta'); + config()->set('graph.tenant_id', 'tenant'); + config()->set('graph.client_id', 'client'); + config()->set('graph.client_secret', 'secret'); + config()->set('graph.scope', 'https://graph.microsoft.com/.default'); + + // Ensure we don't accidentally resolve via supported_policy_types + config()->set('tenantpilot.supported_policy_types', []); +}); + +it('uses graph contract resource path for applyPolicy', function () { + config()->set('graph_contracts.types.mamAppConfiguration', [ + 'resource' => 'deviceAppManagement/targetedManagedAppConfigurations', + 'allowed_select' => ['id', 'displayName'], + 'allowed_expand' => [], + 'type_family' => ['#microsoft.graph.targetedManagedAppConfiguration'], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'A_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'mamAppConfiguration', + policyId: 'A_1', + payload: ['displayName' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1'); + }); +}); diff --git a/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php b/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php new file mode 100644 index 0000000..03266bc --- /dev/null +++ b/tests/Unit/ManagedDeviceAppConfigurationNormalizerTest.php @@ -0,0 +1,45 @@ + 'policy-1', + 'displayName' => 'MAMDevice', + '@odata.type' => '#microsoft.graph.iosMobileAppConfiguration', + 'settings' => [ + [ + 'appConfigKey' => 'com.microsoft.outlook.EmailProfile.AccountType', + 'appConfigKeyType' => 'stringType', + 'appConfigKeyValue' => 'ModernAuth', + ], + [ + 'appConfigKey' => 'com.microsoft.outlook.Mail.FocusedInbox', + 'appConfigKeyType' => 'booleanType', + 'appConfigKeyValue' => 'true', + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'managedDeviceAppConfiguration', 'mobile'); + + $blocks = collect($normalized['settings'] ?? []); + + $appConfig = $blocks->firstWhere('title', 'App configuration settings'); + expect($appConfig)->not->toBeNull(); + expect($appConfig['type'] ?? null)->toBe('table'); + + $rows = collect($appConfig['rows'] ?? []); + $row = $rows->firstWhere('label', 'com.microsoft.outlook.EmailProfile.AccountType'); + expect($row)->not->toBeNull(); + expect($row['value'] ?? null)->toBe('ModernAuth'); + + $boolRow = $rows->firstWhere('label', 'com.microsoft.outlook.Mail.FocusedInbox'); + expect($boolRow)->not->toBeNull(); + expect($boolRow['value'] ?? null)->toBeTrue(); +}); diff --git a/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php b/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php new file mode 100644 index 0000000..8987140 --- /dev/null +++ b/tests/Unit/MicrosoftGraphClientListPoliciesSelectTest.php @@ -0,0 +1,160 @@ + Http::response(['value' => []], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $client->listPolicies('endpointSecurityPolicy', [ + 'access_token' => 'test-token', + ]); + + $client->listPolicies('securityBaselinePolicy', [ + 'access_token' => 'test-token', + ]); + + $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + Http::assertSent(function (Request $request) { + $url = $request->url(); + + if (! str_contains($url, '/deviceManagement/configurationPolicies')) { + return false; + } + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + + expect($query)->toHaveKey('$select'); + + $select = (string) $query['$select']; + + expect($select)->toContain('technologies') + ->and($select)->toContain('templateReference') + ->and($select)->toContain('name') + ->and($select)->not->toContain('@odata.type'); + + expect($select)->not->toContain('displayName'); + expect($select)->not->toContain('version'); + + return true; + }); +}); + +it('retries list policies without $select on select/expand parsing errors', function () { + Http::fake([ + 'graph.microsoft.com/*' => Http::sequence() + ->push([ + 'error' => [ + 'code' => 'BadRequest', + 'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.", + ], + ], 400) + ->push([ + 'error' => [ + 'code' => 'BadRequest', + 'message' => "Parsing OData Select and Expand failed: Could not find a property named 'version' on type 'microsoft.graph.deviceManagementConfigurationPolicy'.", + ], + ], 400) + ->push(['value' => [['id' => 'policy-1', 'name' => 'Policy One']]], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $response = $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + expect($response->successful())->toBeTrue(); + expect($response->data)->toHaveCount(1); + expect($response->warnings)->toContain('Capability fallback applied: removed $select for compatibility.'); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(3); + + [$firstRequest] = $recorded[0]; + [$secondRequest] = $recorded[1]; + [$thirdRequest] = $recorded[2]; + + parse_str((string) parse_url($firstRequest->url(), PHP_URL_QUERY), $firstQuery); + parse_str((string) parse_url($secondRequest->url(), PHP_URL_QUERY), $secondQuery); + parse_str((string) parse_url($thirdRequest->url(), PHP_URL_QUERY), $thirdQuery); + + expect($firstQuery)->toHaveKey('$select'); + expect($secondQuery)->toHaveKey('$select'); + expect($thirdQuery)->not->toHaveKey('$select'); +}); + +it('paginates list policies when nextLink is present', function () { + $nextLink = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$skiptoken=page2'; + + Http::fake([ + 'graph.microsoft.com/*' => Http::sequence() + ->push([ + 'value' => [ + ['id' => 'policy-1', 'name' => 'Policy One'], + ], + '@odata.nextLink' => $nextLink, + ], 200) + ->push([ + 'value' => [ + ['id' => 'policy-2', 'name' => 'Policy Two'], + ], + ], 200), + ]); + + $logger = mock(GraphLogger::class); + $logger->shouldReceive('logRequest')->zeroOrMoreTimes()->andReturnNull(); + $logger->shouldReceive('logResponse')->zeroOrMoreTimes()->andReturnNull(); + + $client = new MicrosoftGraphClient( + logger: $logger, + contracts: app(GraphContractRegistry::class), + ); + + $response = $client->listPolicies('settingsCatalogPolicy', [ + 'access_token' => 'test-token', + ]); + + expect($response->successful())->toBeTrue(); + expect($response->data)->toHaveCount(2); + expect(collect($response->data)->pluck('id')->all())->toMatchArray(['policy-1', 'policy-2']); + + $recorded = Http::recorded(); + + expect($recorded)->toHaveCount(2); + + [$firstRequest] = $recorded[0]; + [$secondRequest] = $recorded[1]; + + expect($firstRequest->url())->toContain('/deviceManagement/configurationPolicies'); + expect($secondRequest->url())->toBe($nextLink); +}); diff --git a/tests/Unit/PolicyCaptureOrchestratorTest.php b/tests/Unit/PolicyCaptureOrchestratorTest.php new file mode 100644 index 0000000..2bfc775 --- /dev/null +++ b/tests/Unit/PolicyCaptureOrchestratorTest.php @@ -0,0 +1,64 @@ +create([ + 'tenant_id' => 'tenant-1', + 'app_client_id' => 'client-1', + 'app_client_secret' => 'secret-1', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_type' => 'mamAppConfiguration', + 'external_id' => 'A_f38e7f58-ac7c-455d-bb0e-f56bf1b3890e', + 'display_name' => 'MAM Example', + 'platform' => 'mobile', + ]); + + $snapshotService = Mockery::mock(PolicySnapshotService::class); + $snapshotService + ->shouldReceive('fetch') + ->once() + ->andReturn([ + 'failure' => [ + 'reason' => 'InternalServerError: upstream', + 'status' => 500, + ], + ]); + + $orchestrator = new PolicyCaptureOrchestrator( + versionService: Mockery::mock(VersionService::class), + snapshotService: $snapshotService, + assignmentFetcher: Mockery::mock(AssignmentFetcher::class), + groupResolver: Mockery::mock(GroupResolver::class), + assignmentFilterResolver: Mockery::mock(AssignmentFilterResolver::class), + scopeTagResolver: Mockery::mock(ScopeTagResolver::class), + ); + + $result = $orchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: true, + includeScopeTags: true, + createdBy: 'admin@example.test', + ); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(500); + expect($result['failure']['reason'])->toContain('InternalServerError'); +}); diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 2bea6c1..9367f93 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -89,6 +89,68 @@ public function request(string $method, string $path, array $options = []): Grap } } +class ConfigurationPolicySettingsSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'name' => 'Endpoint Security Alpha', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path, $options]; + + if ($method === 'GET' && str_contains($path, 'deviceManagement/configurationPolicies/') && str_ends_with($path, '/settings')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'setting-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_firewall_policy_alpha', + 'simpleSettingValue' => [ + 'value' => true, + ], + ], + ], + ], + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + it('hydrates compliance policy scheduled actions into snapshots', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -125,6 +187,45 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('scheduledActionsForRule($expand=scheduledActionConfigurations)'); }); +it('hydrates configuration policy settings into snapshots', function (string $policyType) { + $client = new ConfigurationPolicySettingsSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-endpoint-security', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'esp-123', + 'policy_type' => $policyType, + 'display_name' => 'Endpoint Security Alpha', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('settings'); + expect($result['payload']['settings'])->toHaveCount(1); + expect($result['metadata']['settings_hydration'] ?? null)->toBe('complete'); + + $paths = collect($client->requests) + ->filter(fn (array $entry): bool => ($entry[0] ?? null) === 'GET') + ->map(fn (array $entry): string => (string) ($entry[1] ?? '')) + ->values(); + + expect($paths->contains(fn (string $path): bool => str_contains($path, 'deviceManagement/configurationPolicies/esp-123/settings')))->toBeTrue(); +})->with([ + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); + it('filters mobile app snapshots to metadata-only keys', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -170,6 +271,123 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); +test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andThrow(new \App\Services\Graph\GraphException('InternalServerError: upstream', 500)); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-mam-fallback', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_fallback-policy', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM Config Alpha', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result)->toHaveKey('metadata'); + expect($result)->toHaveKey('warnings'); + expect($result['payload']['id'])->toBe('A_fallback-policy'); + expect($result['payload']['displayName'])->toBe('MAM Config Alpha'); + expect($result['payload']['@odata.type'])->toBe('#microsoft.graph.targetedManagedAppConfiguration'); + expect($result['payload']['platform'])->toBe('iOS'); + expect($result['metadata']['source'])->toBe('metadata_only'); + expect($result['metadata']['original_status'])->toBe(500); + expect($result['warnings'])->toHaveCount(1); + expect($result['warnings'][0])->toContain('Snapshot captured from local metadata only'); + expect($result['warnings'][0])->toContain('Restore preview available, full restore not possible'); +}); + +test('does not fallback to metadata for non-5xx errors', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andThrow(new \App\Services\Graph\GraphException('NotFound', 404)); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-404', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_missing', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'Missing Policy', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(404); + expect($result['failure']['reason'])->toContain('NotFound'); +}); + +test('falls back to metadata-only when graph client returns failed response for mamAppConfiguration', function () { + $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); + $client->shouldReceive('getPolicy') + ->once() + ->andReturn(new \App\Services\Graph\GraphResponse( + success: false, + data: [ + 'error' => [ + 'code' => 'InternalServerError', + 'message' => 'Upstream MAM failure', + ], + ], + status: 500, + errors: [['code' => 'InternalServerError', 'message' => 'Upstream MAM failure']], + meta: [ + 'client_request_id' => 'client-req-1', + 'request_id' => 'req-1', + ], + )); + + app()->instance(\App\Services\Graph\GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-mam-fallback-response', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'A_resp_fallback', + 'policy_type' => 'mamAppConfiguration', + 'display_name' => 'MAM Config Response', + 'platform' => 'iOS', + ]); + + $service = app(\App\Services\Intune\PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['metadata']['source'])->toBe('metadata_only'); + expect($result['metadata']['original_status'])->toBe(500); + expect($result['metadata']['original_failure'])->toContain('InternalServerError'); +}); + class WindowsUpdateRingSnapshotGraphClient implements GraphClientInterface { public array $requests = []; @@ -251,3 +469,78 @@ public function request(string $method, string $path, array $options = []): Grap expect($result['payload']['featureUpdatesDeferralPeriodInDays'])->toBe(14); expect($result['metadata']['properties_hydration'] ?? null)->toBe('complete'); }); + +class FailedSnapshotGraphClient implements GraphClientInterface +{ + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse( + success: false, + data: [], + status: 500, + errors: [], + warnings: [], + meta: [ + 'error_code' => 'InternalServerError', + 'error_message' => 'An internal server error has occurred', + 'request_id' => 'req-123', + 'client_request_id' => 'client-456', + ], + ); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } +} + +it('returns actionable reasons when graph snapshot fails', function () { + app()->instance(GraphClientInterface::class, new FailedSnapshotGraphClient); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-failure', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'mam-123', + 'policy_type' => 'deviceCompliancePolicy', + 'display_name' => 'Compliance Config', + 'platform' => 'mobile', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('failure'); + expect($result['failure']['status'])->toBe(500); + expect($result['failure']['reason'])->toContain('InternalServerError'); + expect($result['failure']['reason'])->toContain('An internal server error has occurred'); + expect($result['failure']['reason'])->toContain('client_request_id=client-456'); + expect($result['failure']['reason'])->toContain('request_id=req-123'); +}); diff --git a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php index fcc9a14..d4ddd36 100644 --- a/tests/Unit/SettingsCatalogPolicyNormalizerTest.php +++ b/tests/Unit/SettingsCatalogPolicyNormalizerTest.php @@ -31,3 +31,139 @@ expect($rows)->toHaveCount(1); expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); }); + +it('builds a settings table for endpoint security configuration policies', function (string $policyType) { + $normalizer = app(SettingsCatalogPolicyNormalizer::class); + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, $policyType, 'windows'); + + $rows = $normalized['settings_table']['rows'] ?? []; + + expect($rows)->toHaveCount(1); + expect($rows[0]['definition_id'] ?? null)->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +})->with([ + 'endpointSecurityPolicy', + 'securityBaselinePolicy', +]); + +it('prettifies endpoint security firewall rules settings for display', function () { + $normalizer = app(SettingsCatalogPolicyNormalizer::class); + + $groupDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}'; + $nameDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_displayname'; + $directionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_direction'; + $actionDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_action'; + $interfaceTypesDefinitionId = 'vendor_msft_firewall_mdmstore_firewallrules_{FirewallRuleId}_interfacetypes'; + + $snapshot = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 'rule-1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance', + 'settingDefinitionId' => $groupDefinitionId, + 'groupSettingCollectionValue' => [ + [ + 'children' => [ + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => $nameDefinitionId, + 'simpleSettingValue' => [ + 'value' => 'Test0', + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => $directionDefinitionId, + 'choiceSettingValue' => [ + 'value' => "{$directionDefinitionId}_in", + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance', + 'settingDefinitionId' => $actionDefinitionId, + 'choiceSettingValue' => [ + 'value' => "{$actionDefinitionId}_allow", + ], + ], + [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance', + 'settingDefinitionId' => $interfaceTypesDefinitionId, + 'choiceSettingCollectionValue' => [ + [ + 'value' => "{$interfaceTypesDefinitionId}_lan", + 'children' => [], + ], + [ + 'value' => "{$interfaceTypesDefinitionId}_remoteaccess", + 'children' => [], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]; + + $normalized = $normalizer->normalize($snapshot, 'endpointSecurityPolicy', 'windows'); + $rows = collect($normalized['settings_table']['rows'] ?? []); + + $groupRow = $rows->firstWhere('definition_id', $groupDefinitionId); + expect($groupRow)->not->toBeNull(); + expect($groupRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($groupRow['definition'] ?? null)->toBe('Firewall rule'); + expect($groupRow['data_type'] ?? null)->toBe('Group'); + expect($groupRow['value'] ?? null)->toBe('(group)'); + + $nameRow = $rows->firstWhere('definition_id', $nameDefinitionId); + expect($nameRow)->not->toBeNull(); + expect($nameRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($nameRow['definition'] ?? null)->toBe('Name'); + expect($nameRow['value'] ?? null)->toBe('Test0'); + + $directionRow = $rows->firstWhere('definition_id', $directionDefinitionId); + expect($directionRow)->not->toBeNull(); + expect($directionRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($directionRow['definition'] ?? null)->toBe('Direction'); + expect($directionRow['data_type'] ?? null)->toBe('Choice'); + expect($directionRow['value'] ?? null)->toBe('Inbound'); + + $actionRow = $rows->firstWhere('definition_id', $actionDefinitionId); + expect($actionRow)->not->toBeNull(); + expect($actionRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($actionRow['definition'] ?? null)->toBe('Action'); + expect($actionRow['data_type'] ?? null)->toBe('Choice'); + expect($actionRow['value'] ?? null)->toBe('Allow'); + + $interfaceTypesRow = $rows->firstWhere('definition_id', $interfaceTypesDefinitionId); + expect($interfaceTypesRow)->not->toBeNull(); + expect($interfaceTypesRow['category'] ?? null)->toBe('Windows Firewall Rules'); + expect($interfaceTypesRow['definition'] ?? null)->toBe('Interface types'); + expect($interfaceTypesRow['data_type'] ?? null)->toBe('Choice'); + expect($interfaceTypesRow['value'] ?? null)->toBe('LAN, Remote access'); +}); From d6a57c1828d784431831665bc6edd4087684ec40 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 3 Jan 2026 03:27:28 +0000 Subject: [PATCH 08/18] feat/023-endpoint-security-restore (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/-/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/24 --- .../checklists/requirements.md | 14 +++ specs/023-endpoint-security-restore/plan.md | 32 +++++++ specs/023-endpoint-security-restore/spec.md | 93 +++++++++++++++++++ specs/023-endpoint-security-restore/tasks.md | 32 +++++++ 4 files changed, 171 insertions(+) create mode 100644 specs/023-endpoint-security-restore/checklists/requirements.md create mode 100644 specs/023-endpoint-security-restore/plan.md create mode 100644 specs/023-endpoint-security-restore/spec.md create mode 100644 specs/023-endpoint-security-restore/tasks.md diff --git a/specs/023-endpoint-security-restore/checklists/requirements.md b/specs/023-endpoint-security-restore/checklists/requirements.md new file mode 100644 index 0000000..2da1d80 --- /dev/null +++ b/specs/023-endpoint-security-restore/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (023) + +**Created**: 2026-01-03 +**Feature**: [spec.md](../spec.md) + +- [ ] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`. +- [ ] Restore preview validates template existence and reports missing/ambiguous templates. +- [ ] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message. +- [ ] Settings instances are validated against resolved template definitions before execution. +- [ ] Template mapping strategy is defined for cross-tenant differences (if required) and is tested. +- [ ] Restore create + update paths for Endpoint Security policies are covered by automated tests. +- [ ] Assignments mapping/application for Endpoint Security policies are covered by automated tests. +- [ ] Audit log entries exist for restore execution attempts (success and failure). + diff --git a/specs/023-endpoint-security-restore/plan.md b/specs/023-endpoint-security-restore/plan.md new file mode 100644 index 0000000..c843861 --- /dev/null +++ b/specs/023-endpoint-security-restore/plan.md @@ -0,0 +1,32 @@ +# Plan: Endpoint Security Policy Restore (023) + +**Branch**: `feat/023-endpoint-security-restore` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md) + +## Goal +Enable full restore execution for Endpoint Security Policies (`endpointSecurityPolicy`) instead of preview-only, with defensive validation around templates and settings payloads. + +## Approach +1. Enable restore execution in `config/tenantpilot.php` by switching `endpointSecurityPolicy.restore` from `preview-only` to `enabled`. +2. Add template existence validation during restore preview: + - Resolve the snapshot’s `templateReference` (family/id/display name where available). + - Confirm the referenced template is resolvable in the target tenant before execution. + - Surface warnings in preview and fail execution with a clear error when missing. +3. Add settings instance validation prior to execution: + - Resolve template definitions for the target tenant. + - Validate that settings instances are structurally compatible with the resolved template. + - Treat validation failures as preview warnings, and block execution when the payload cannot be made safe. +4. Ensure restore uses the existing generic configuration policy create/update flow: + - Create when no match exists; update when matched (per existing restore matching rules). + - Apply assignments using existing mapping logic. +5. Add targeted tests covering: + - Create + update restore execution for `endpointSecurityPolicy`. + - Preview warnings and execution failure when template is missing. + - Settings validation failure paths. + - Assignment application expectations. + +## Decisions / Notes +- Assume template identifiers may differ across tenants; prefer mapping by `templateFamily` with display-name fallback when required. +- Safety-first: if template resolution is ambiguous, treat as missing and block execution. + diff --git a/specs/023-endpoint-security-restore/spec.md b/specs/023-endpoint-security-restore/spec.md new file mode 100644 index 0000000..81b3fd5 --- /dev/null +++ b/specs/023-endpoint-security-restore/spec.md @@ -0,0 +1,93 @@ +# Feature Specification: Enable Endpoint Security Policy Restore (023) + +**Feature Branch**: `feat/023-endpoint-security-restore` +**Created**: 2026-01-03 +**Status**: Draft +**Priority**: P1 (Quick Win) + +## Context +Endpoint Security Policies are already in the `tenantpilot.php` config as `endpointSecurityPolicy` with `restore => 'preview-only'`. Based on Microsoft's recommendation to use the unified `deviceManagement/configurationPolicies` endpoint (over the deprecated `intents` API for new creations), we should enable full restore for this type. + +This is a **configuration-only change** with additional validation/testing, not a new policy type implementation. + +## User Scenarios & Testing + +### User Story 1 — Restore Endpoint Security Policies (Priority: P1) +As an admin, I want to restore Endpoint Security Policies (Firewall, Defender, ASR, BitLocker, etc.) from backup, so I can recover from configuration errors or replicate security baselines across tenants. + +**Why this priority**: These are high-impact security policies; restore is a core safety feature. + +**Independent Test**: Restore an Endpoint Security Policy snapshot; verify settings and assignments are applied correctly. + +**Acceptance Scenarios** +1. Given an Endpoint Security Policy snapshot (e.g., Firewall), when I restore to a tenant without that policy, then a new policy is created with matching settings. +2. Given an Endpoint Security Policy snapshot, when I restore to a tenant with an existing policy (name match), then the policy is updated. +3. Given such a policy has assignments, when I restore, then assignments are mapped and applied. + +### User Story 2 — Template Validation (Priority: P1) +As an admin, I want clear warnings if an Endpoint Security template is not available in the target tenant, so I understand restore limitations. + +**Why this priority**: Templates are version-dependent; missing templates must be surfaced. + +**Independent Test**: Attempt to restore a policy referencing a template not present in target; verify preview shows a warning. + +**Acceptance Scenarios** +1. Given a policy snapshot references a template ID, when I restore to a tenant without that template, then preview warns about missing template. +2. Given such a scenario, when I execute restore, then the operation fails gracefully with a clear error message. + +### User Story 3 — Settings Instance Consistency (Priority: P2) +As an admin, I want settings instances to be validated against template definitions, so restored policies are valid. + +**Why this priority**: Settings must match template structure; invalid settings break policies. + +**Independent Test**: Restore a policy with settings; verify Graph API accepts the settings payload. + +**Acceptance Scenarios** +1. Given a policy snapshot with settings, when I restore, then settings are validated before submission to Graph API. +2. Given settings validation detects structural issues, when running preview, then warnings indicate which settings may be problematic. + +## Requirements + +### Functional Requirements +- **FR-001**: Change `restore` value from `'preview-only'` to `'enabled'` for `endpointSecurityPolicy` in config +- **FR-002**: Add template existence validation in restore preview +- **FR-003**: Ensure settings instance validation against template structure +- **FR-004**: Update Graph contract for `endpointSecurityPolicy` if needed (may already exist) +- **FR-005**: Add template ID mapping (if templates have different IDs across tenants) +- **FR-006**: Add comprehensive restore tests for common Endpoint Security policy types: + - Antivirus (Defender) + - Firewall + - Disk Encryption (BitLocker) + - Attack Surface Reduction (ASR) + - Account Protection + +### Non-Functional Requirements +- **NFR-001**: Restore preview must complete within 5 seconds for typical policy +- **NFR-002**: Template validation must not significantly slow down preview +- **NFR-003**: All common Endpoint Security policy types must be covered by tests + +### Graph API Details +- **Endpoint**: `https://graph.microsoft.com/beta/deviceManagement/configurationPolicies` +- **Filter** (if needed): `templateReference/templateFamily eq 'endpointSecurity...'` +- **Template Families**: + - `endpointSecurityAntivirus` + - `endpointSecurityFirewall` + - `endpointSecurityDiskEncryption` + - `endpointSecurityAttackSurfaceReduction` + - `endpointSecurityAccountProtection` + - etc. +- **Required Permissions**: `DeviceManagementConfiguration.ReadWrite.All` + +### Known Considerations +- **Template Versioning**: Templates can evolve; settings structure may change +- **Platform Differences**: Some templates are Windows 10 only, others support Windows 11+ +- **Settings Validation**: Graph API will reject invalid settings; catch this in preview + +## Success Criteria +- **SC-001**: Config change applied: `endpointSecurityPolicy` has `restore => 'enabled'` +- **SC-002**: Restore preview shows accurate change summary for Endpoint Security policies +- **SC-003**: Restore executes successfully for common policy types (Firewall, Antivirus, BitLocker) +- **SC-004**: Template existence validation catches missing templates before execution +- **SC-005**: Settings instance validation prevents invalid payloads +- **SC-006**: No regressions in sync or backup for this policy type +- **SC-007**: Feature tests cover restore success and failure scenarios diff --git a/specs/023-endpoint-security-restore/tasks.md b/specs/023-endpoint-security-restore/tasks.md new file mode 100644 index 0000000..049479b --- /dev/null +++ b/specs/023-endpoint-security-restore/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Endpoint Security Policy Restore (023) + +**Branch**: `feat/023-endpoint-security-restore` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Inventory & Design +- [ ] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services). +- [ ] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls. +- [ ] T004 Define settings instance validation rules (warning vs block) for restore preview/execution. + +## Phase 3: Tests (TDD) +- [ ] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`. +- [ ] T006 Add feature tests for preview warnings when template is missing. +- [ ] T007 Add feature tests asserting restore execution fails gracefully when template is missing. +- [ ] T008 Add tests for settings validation failure paths (invalid/unknown settings instances). +- [ ] T009 Add feature tests asserting assignments are applied for endpoint security policies. + +## Phase 4: Implementation +- [ ] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`. +- [ ] T011 Implement template existence validation in restore preview and execution gating. +- [ ] T012 Implement settings instance validation against resolved template definitions. +- [ ] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference. +- [ ] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic. + +## Phase 5: Verification +- [ ] T015 Run targeted tests. +- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`). + From d120ed7c926ba4ac8a2c1bb6def90a69e652b1b4 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sat, 3 Jan 2026 22:44:08 +0000 Subject: [PATCH 09/18] feat: endpoint security restore execution (023) (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added a resolver/validation flow that fetches endpoint security template definitions and enforces them before CREATE/PATCH so we don’t call Graph with invalid settings. Hardened restore endpoint resolution (built-in fallback to deviceManagement/configurationPolicies, clearer error metadata, preview-only fallback when metadata is missing) and exposed Graph path/method in restore UI details. Stripped read-only fields when PATCHing endpointSecurityIntent so the request no longer fails with “properties not patchable”. Added regression tests covering endpoint security restore, intent sanitization, unknown type safety, Graph error metadata, and endpoint resolution behavior. Testing GraphClientEndpointResolutionTest.php ./vendor/bin/pint --dirty Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/25 --- app/Services/Graph/MicrosoftGraphClient.php | 23 +- .../ConfigurationPolicyTemplateResolver.php | 388 ++++++++++++++++++ app/Services/Intune/RestoreRiskChecker.php | 99 ++++- app/Services/Intune/RestoreService.php | 139 ++++++- config/graph_contracts.php | 25 ++ config/tenantpilot.php | 2 +- .../entries/restore-results.blade.php | 8 +- ...tSecurityIntentRestoreSanitizationTest.php | 102 +++++ .../EndpointSecurityPolicyRestore023Test.php | 265 ++++++++++++ tests/Feature/PolicyTypes017Test.php | 2 +- .../Feature/RestoreGraphErrorMetadataTest.php | 102 +++++ .../RestoreUnknownPolicyTypeSafetyTest.php | 117 ++++++ .../GraphClientEndpointResolutionTest.php | 37 ++ 13 files changed, 1285 insertions(+), 24 deletions(-) create mode 100644 app/Services/Intune/ConfigurationPolicyTemplateResolver.php create mode 100644 tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php create mode 100644 tests/Feature/EndpointSecurityPolicyRestore023Test.php create mode 100644 tests/Feature/RestoreGraphErrorMetadataTest.php create mode 100644 tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php diff --git a/app/Services/Graph/MicrosoftGraphClient.php b/app/Services/Graph/MicrosoftGraphClient.php index 3f47bab..094fe16 100644 --- a/app/Services/Graph/MicrosoftGraphClient.php +++ b/app/Services/Graph/MicrosoftGraphClient.php @@ -781,8 +781,17 @@ private function endpointFor(string $policyType): string return $contractResource; } - $supported = config('tenantpilot.supported_policy_types', []); - foreach ($supported as $type) { + $builtinEndpoint = $this->builtinEndpointFor($policyType); + if ($builtinEndpoint !== null) { + return $builtinEndpoint; + } + + $types = array_merge( + config('tenantpilot.supported_policy_types', []), + config('tenantpilot.foundation_types', []), + ); + + foreach ($types as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { return $type['endpoint']; } @@ -791,6 +800,16 @@ private function endpointFor(string $policyType): string return 'deviceManagement/'.$policyType; } + private function builtinEndpointFor(string $policyType): ?string + { + return match ($policyType) { + 'settingsCatalogPolicy', + 'endpointSecurityPolicy', + 'securityBaselinePolicy' => 'deviceManagement/configurationPolicies', + default => null, + }; + } + private function getAccessToken(array $context): string { $tenant = $context['tenant'] ?? $this->tenantId; diff --git a/app/Services/Intune/ConfigurationPolicyTemplateResolver.php b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php new file mode 100644 index 0000000..3432c59 --- /dev/null +++ b/app/Services/Intune/ConfigurationPolicyTemplateResolver.php @@ -0,0 +1,388 @@ +> + */ + private array $templateCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $familyCache = []; + + /** + * @var array,reason:?string}>> + */ + private array $templateDefinitionCache = []; + + public function __construct( + private readonly GraphClientInterface $graphClient, + ) {} + + /** + * @param array $templateReference + * @param array $graphOptions + * @return array{success:bool,template_id:?string,template_reference:?array,reason:?string,warnings:array} + */ + public function resolveTemplateReference(Tenant $tenant, array $templateReference, array $graphOptions = []): array + { + $warnings = []; + + $templateId = $this->extractString($templateReference, ['templateId', 'TemplateId']); + $templateFamily = $this->extractString($templateReference, ['templateFamily', 'TemplateFamily']); + $templateDisplayName = $this->extractString($templateReference, ['templateDisplayName', 'TemplateDisplayName']); + $templateDisplayVersion = $this->extractString($templateReference, ['templateDisplayVersion', 'TemplateDisplayVersion']); + + if ($templateId !== null) { + $templateOutcome = $this->getTemplate($tenant, $templateId, $graphOptions); + + if ($templateOutcome['success']) { + return [ + 'success' => true, + 'template_id' => $templateId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $templateOutcome['reason'] ?? "Template '{$templateId}' is not available in the tenant.", + 'warnings' => $warnings, + ]; + } + } + + if ($templateFamily === null) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => 'Template reference is missing templateFamily and cannot be resolved.', + 'warnings' => $warnings, + ]; + } + + $listOutcome = $this->listTemplatesByFamily($tenant, $templateFamily, $graphOptions); + + if (! $listOutcome['success']) { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $listOutcome['reason'] ?? "Unable to list templates for family '{$templateFamily}'.", + 'warnings' => $warnings, + ]; + } + + $candidates = $this->chooseTemplateCandidate( + templates: $listOutcome['templates'], + templateDisplayName: $templateDisplayName, + templateDisplayVersion: $templateDisplayVersion, + ); + + if (count($candidates) !== 1) { + $reason = count($candidates) === 0 + ? "No templates found for family '{$templateFamily}'." + : "Multiple templates found for family '{$templateFamily}' (cannot resolve automatically)."; + + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => $reason, + 'warnings' => $warnings, + ]; + } + + $candidate = $candidates[0]; + $resolvedId = is_array($candidate) ? ($candidate['id'] ?? null) : null; + + if (! is_string($resolvedId) || $resolvedId === '') { + return [ + 'success' => false, + 'template_id' => null, + 'template_reference' => null, + 'reason' => "Template candidate for family '{$templateFamily}' is missing an id.", + 'warnings' => $warnings, + ]; + } + + if ($templateId !== null && $templateId !== $resolvedId) { + $warnings[] = sprintf("TemplateId '%s' not found; mapped to '%s' via templateFamily.", $templateId, $resolvedId); + } + + $templateReference['templateId'] = $resolvedId; + + if (! isset($templateReference['templateDisplayName']) && isset($candidate['displayName'])) { + $templateReference['templateDisplayName'] = $candidate['displayName']; + } + + if (! isset($templateReference['templateDisplayVersion']) && isset($candidate['displayVersion'])) { + $templateReference['templateDisplayVersion'] = $candidate['displayVersion']; + } + + return [ + 'success' => true, + 'template_id' => $resolvedId, + 'template_reference' => $templateReference, + 'reason' => null, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,template:?array,reason:?string} + */ + public function getTemplate(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateCache[$tenantKey][$templateId])) { + return $this->templateCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform'])); + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => false, + 'template' => null, + 'reason' => $response->meta['error_message'] ?? 'Template lookup failed.', + ]; + } + + return $this->templateCache[$tenantKey][$templateId] = [ + 'success' => true, + 'template' => $response->data, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,templates:array,reason:?string} + */ + public function listTemplatesByFamily(Tenant $tenant, string $templateFamily, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + $cacheKey = strtolower($templateFamily); + + if (isset($this->familyCache[$tenantKey][$cacheKey])) { + return $this->familyCache[$tenantKey][$cacheKey]; + } + + $escapedFamily = str_replace("'", "''", $templateFamily); + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$filter' => "templateFamily eq '{$escapedFamily}'", + '$top' => 999, + ], + ]); + + $response = $this->graphClient->request('GET', '/deviceManagement/configurationPolicyTemplates', $context); + + if ($response->failed()) { + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => false, + 'templates' => [], + 'reason' => $response->meta['error_message'] ?? 'Template list failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? array_values(array_filter($value, static fn ($item) => is_array($item))) : []; + + return $this->familyCache[$tenantKey][$cacheKey] = [ + 'success' => true, + 'templates' => $templates, + 'reason' => null, + ]; + } + + /** + * @param array $graphOptions + * @return array{success:bool,definition_ids:array,reason:?string} + */ + public function fetchTemplateSettingDefinitionIds(Tenant $tenant, string $templateId, array $graphOptions = []): array + { + $tenantKey = $this->tenantKey($tenant, $graphOptions); + + if (isset($this->templateDefinitionCache[$tenantKey][$templateId])) { + return $this->templateDefinitionCache[$tenantKey][$templateId]; + } + + $context = array_merge($tenant->graphOptions(), Arr::except($graphOptions, ['platform']), [ + 'query' => [ + '$expand' => 'settingDefinitions', + '$top' => 999, + ], + ]); + + $path = sprintf('/deviceManagement/configurationPolicyTemplates/%s/settingTemplates', urlencode($templateId)); + $response = $this->graphClient->request('GET', $path, $context); + + if ($response->failed()) { + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => false, + 'definition_ids' => [], + 'reason' => $response->meta['error_message'] ?? 'Template definitions lookup failed.', + ]; + } + + $value = $response->data['value'] ?? []; + $templates = is_array($value) ? $value : []; + $definitionIds = []; + + foreach ($templates as $settingTemplate) { + if (! is_array($settingTemplate)) { + continue; + } + + $definitions = $settingTemplate['settingDefinitions'] ?? null; + + if (! is_array($definitions)) { + continue; + } + + foreach ($definitions as $definition) { + if (! is_array($definition)) { + continue; + } + + $id = $definition['id'] ?? null; + + if (is_string($id) && $id !== '') { + $definitionIds[] = $id; + } + } + } + + $definitionIds = array_values(array_unique($definitionIds)); + + return $this->templateDefinitionCache[$tenantKey][$templateId] = [ + 'success' => true, + 'definition_ids' => $definitionIds, + 'reason' => null, + ]; + } + + /** + * @param array $settings + * @return array + */ + public function extractSettingDefinitionIds(array $settings): array + { + $ids = []; + + $walk = function (mixed $node) use (&$walk, &$ids): void { + if (! is_array($node)) { + return; + } + + foreach ($node as $key => $value) { + if (is_string($key) && strtolower($key) === 'settingdefinitionid' && is_string($value) && $value !== '') { + $ids[] = $value; + } + + $walk($value); + } + }; + + $walk($settings); + + return array_values(array_unique($ids)); + } + + /** + * @param array $templates + * @return array + */ + private function chooseTemplateCandidate(array $templates, ?string $templateDisplayName, ?string $templateDisplayVersion): array + { + $candidates = $templates; + + $active = array_values(array_filter($candidates, static function (array $template): bool { + $state = $template['lifecycleState'] ?? null; + + return is_string($state) && strtolower($state) === 'active'; + })); + + if ($active !== []) { + $candidates = $active; + } + + if ($templateDisplayVersion !== null) { + $byVersion = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayVersion): bool { + $version = $template['displayVersion'] ?? null; + + return is_string($version) && $version === $templateDisplayVersion; + })); + + if ($byVersion !== []) { + $candidates = $byVersion; + } + } + + if ($templateDisplayName !== null) { + $byName = array_values(array_filter($candidates, static function (array $template) use ($templateDisplayName): bool { + $name = $template['displayName'] ?? null; + + return is_string($name) && $name === $templateDisplayName; + })); + + if ($byName !== []) { + $candidates = $byName; + } + } + + return $candidates; + } + + /** + * @param array $payload + * @param array $keys + */ + private function extractString(array $payload, array $keys): ?string + { + $normalized = array_map('strtolower', $keys); + + foreach ($payload as $key => $value) { + if (! is_string($key) || ! in_array(strtolower($key), $normalized, true)) { + continue; + } + + if (is_string($value) && trim($value) !== '') { + return $value; + } + } + + return null; + } + + /** + * @param array $graphOptions + */ + private function tenantKey(Tenant $tenant, array $graphOptions): string + { + $tenantId = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + return (string) $tenantId; + } +} diff --git a/app/Services/Intune/RestoreRiskChecker.php b/app/Services/Intune/RestoreRiskChecker.php index 35383a4..84be502 100644 --- a/app/Services/Intune/RestoreRiskChecker.php +++ b/app/Services/Intune/RestoreRiskChecker.php @@ -16,6 +16,7 @@ class RestoreRiskChecker { public function __construct( private readonly GroupResolver $groupResolver, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, ) {} /** @@ -40,6 +41,7 @@ public function check(Tenant $tenant, BackupSet $backupSet, ?array $selectedItem $results[] = $this->checkOrphanedGroups($tenant, $policyItems, $groupMapping); $results[] = $this->checkMetadataOnlySnapshots($policyItems); $results[] = $this->checkPreviewOnlyPolicies($policyItems); + $results[] = $this->checkEndpointSecurityTemplates($tenant, $policyItems); $results[] = $this->checkMissingPolicies($tenant, $policyItems); $results[] = $this->checkStalePolicies($tenant, $policyItems); $results[] = $this->checkMissingScopeTagsInScope($items, $policyItems, $selectedItemIds !== null); @@ -229,6 +231,91 @@ private function checkPreviewOnlyPolicies(Collection $policyItems): ?array ]; } + /** + * Validate that Endpoint Security policy templates referenced by snapshots exist in the tenant. + * + * @param Collection $policyItems + * @return array{code: string, severity: string, title: string, message: string, meta: array}|null + */ + private function checkEndpointSecurityTemplates(Tenant $tenant, Collection $policyItems): ?array + { + $issues = []; + $hasRestoreEnabled = false; + $graphOptions = $tenant->graphOptions(); + + foreach ($policyItems as $item) { + if ($item->policy_type !== 'endpointSecurityPolicy') { + continue; + } + + $restoreMode = $this->resolveRestoreMode($item->policy_type); + + if ($restoreMode !== 'preview-only') { + $hasRestoreEnabled = true; + } + + $payload = is_array($item->payload) ? $item->payload : []; + $templateReference = $payload['templateReference'] ?? null; + + if (is_string($templateReference)) { + $decoded = json_decode($templateReference, true); + $templateReference = is_array($decoded) ? $decoded : null; + } + + if (! is_array($templateReference)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'reason' => 'Missing templateReference in snapshot.', + ]; + + continue; + } + + $outcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($outcome['success'] ?? false)) { + $issues[] = [ + 'backup_item_id' => $item->id, + 'policy_identifier' => $item->policy_identifier, + 'label' => $item->resolvedDisplayName(), + 'template_id' => $templateReference['templateId'] ?? null, + 'template_family' => $templateReference['templateFamily'] ?? null, + 'reason' => $outcome['reason'] ?? 'Template could not be resolved in the tenant.', + ]; + } + } + + if ($issues === []) { + return [ + 'code' => 'endpoint_security_templates', + 'severity' => 'safe', + 'title' => 'Endpoint security templates', + 'message' => 'All referenced Endpoint Security templates are available.', + 'meta' => [ + 'count' => 0, + ], + ]; + } + + $severity = $hasRestoreEnabled ? 'blocking' : 'warning'; + $message = $hasRestoreEnabled + ? 'Some Endpoint Security templates are missing or cannot be resolved in the tenant.' + : 'Some Endpoint Security templates are missing or cannot be resolved (execution is preview-only).'; + + return [ + 'code' => 'endpoint_security_templates', + 'severity' => $severity, + 'title' => 'Endpoint security templates', + 'message' => $message, + 'meta' => [ + 'count' => count($issues), + 'items' => $this->truncateList($issues, 10), + ], + ]; + } + /** * Detect snapshots that were captured as metadata-only. * @@ -669,7 +756,17 @@ private function resolveRestoreMode(?string $policyType): string { $meta = $this->resolveTypeMeta($policyType); - return (string) ($meta['restore'] ?? 'enabled'); + if ($meta === []) { + return 'preview-only'; + } + + $restore = $meta['restore'] ?? 'enabled'; + + if (! is_string($restore) || $restore === '') { + return 'enabled'; + } + + return $restore; } private function resolveTypeLabel(?string $policyType): string diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index e75040a..b62ed49 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -27,6 +27,7 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, + private readonly ConfigurationPolicyTemplateResolver $templateResolver, private readonly AssignmentRestoreService $assignmentRestoreService, private readonly FoundationMappingService $foundationMappingService, ) {} @@ -430,12 +431,13 @@ public function execute( $createdPolicyMode = null; $settingsApplyEligible = false; - if ($item->policy_type === 'settingsCatalogPolicy') { + if (in_array($item->policy_type, ['settingsCatalogPolicy', 'endpointSecurityPolicy'], true)) { + $policyType = $item->policy_type; $settings = $this->extractSettingsCatalogSettings($originalPayload); $policyPayload = $this->stripSettingsFromPayload($payload); $response = $this->graphClient->applyPolicy( - $item->policy_type, + $policyType, $item->policy_identifier, $policyPayload, $graphOptions + ['method' => $updateMethod] @@ -443,8 +445,19 @@ public function execute( $settingsApplyEligible = $response->successful(); - if ($response->failed() && $this->shouldAttemptPolicyCreate($item->policy_type, $response)) { + if ($response->failed() && $this->shouldAttemptPolicyCreate($policyType, $response)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -488,6 +501,7 @@ public function execute( if ($settingsApplyEligible && $settings !== []) { [$settingsApply, $itemStatus] = $this->applySettingsCatalogPolicySettings( + policyType: $policyType, policyId: $item->policy_identifier, settings: $settings, graphOptions: $graphOptions, @@ -496,7 +510,18 @@ public function execute( if ($itemStatus === 'manual_required' && $settingsApply !== null && $this->shouldAttemptSettingsCatalogCreate($settingsApply)) { + if ($policyType === 'endpointSecurityPolicy') { + $originalPayload = $this->prepareEndpointSecurityPolicyForCreate( + tenant: $tenant, + originalPayload: $originalPayload, + settings: $settings, + graphOptions: $graphOptions, + context: $context, + ); + } + $createOutcome = $this->createSettingsCatalogPolicy( + policyType: $policyType, originalPayload: $originalPayload, settings: $settings, graphOptions: $graphOptions, @@ -539,14 +564,6 @@ public function execute( ]; } } - } elseif ($settingsApplyEligible && $settings !== []) { - $settingsApply = [ - 'total' => count($settings), - 'applied' => 0, - 'failed' => count($settings), - 'manual_required' => 0, - 'issues' => [], - ]; } } else { if ($item->policy_type === 'appProtectionPolicy') { @@ -659,6 +676,8 @@ public function execute( 'graph_error_code' => $response->meta['error_code'] ?? null, 'graph_request_id' => $response->meta['request_id'] ?? null, 'graph_client_request_id' => $response->meta['client_request_id'] ?? null, + 'graph_method' => $response->meta['method'] ?? null, + 'graph_path' => $response->meta['path'] ?? null, ]; $hardFailures++; @@ -914,6 +933,11 @@ private function resolveTypeMeta(string $policyType): array private function resolveRestoreMode(string $policyType): string { $meta = $this->resolveTypeMeta($policyType); + + if ($meta === []) { + return 'preview-only'; + } + $restore = $meta['restore'] ?? 'enabled'; if (! is_string($restore) || $restore === '') { @@ -960,6 +984,10 @@ private function isNotFoundResponse(object $response): bool $code = strtolower((string) ($response->meta['error_code'] ?? '')); $message = strtolower((string) ($response->meta['error_message'] ?? '')); + if ($message !== '' && str_contains($message, 'resource not found for the segment')) { + return false; + } + if ($code !== '' && (str_contains($code, 'notfound') || str_contains($code, 'resource'))) { return true; } @@ -1508,15 +1536,16 @@ private function resolveSettingsCatalogSettingId(array $setting): ?string * @return array{0: array{total:int,applied:int,failed:int,manual_required:int,issues:array>}, 1: string} */ private function applySettingsCatalogPolicySettings( + string $policyType, string $policyId, array $settings, array $graphOptions, array $context, ): array { - $method = $this->contracts->settingsWriteMethod('settingsCatalogPolicy'); - $path = $this->contracts->settingsWritePath('settingsCatalogPolicy', $policyId); - $bodyShape = strtolower($this->contracts->settingsWriteBodyShape('settingsCatalogPolicy')); - $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape('settingsCatalogPolicy'); + $method = $this->contracts->settingsWriteMethod($policyType); + $path = $this->contracts->settingsWritePath($policyType, $policyId); + $bodyShape = strtolower($this->contracts->settingsWriteBodyShape($policyType)); + $fallbackShape = $this->contracts->settingsWriteFallbackBodyShape($policyType); $buildIssues = function (string $reason) use ($settings): array { $issues = []; @@ -1549,7 +1578,7 @@ private function applySettingsCatalogPolicySettings( ]; } - $sanitized = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $sanitized = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if (! is_array($sanitized) || $sanitized === []) { return [ @@ -1683,14 +1712,15 @@ private function shouldAttemptSettingsCatalogCreate(array $settingsApply): bool * @return array{success:bool,policy_id:?string,response:?object,mode:string} */ private function createSettingsCatalogPolicy( + string $policyType, array $originalPayload, array $settings, array $graphOptions, array $context, string $fallbackName, ): array { - $resource = $this->contracts->resourcePath('settingsCatalogPolicy') ?? 'deviceManagement/configurationPolicies'; - $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload('settingsCatalogPolicy', $settings); + $resource = $this->contracts->resourcePath($policyType) ?? 'deviceManagement/configurationPolicies'; + $sanitizedSettings = $this->contracts->sanitizeSettingsApplyPayload($policyType, $settings); if ($sanitizedSettings === []) { return [ @@ -1747,6 +1777,79 @@ private function createSettingsCatalogPolicy( ]; } + /** + * @param array $originalPayload + * @param array $settings + * @param array $graphOptions + * @param array $context + * @return array + */ + private function prepareEndpointSecurityPolicyForCreate( + Tenant $tenant, + array $originalPayload, + array $settings, + array $graphOptions, + array $context, + ): array { + $templateReference = $this->resolvePayloadArray($originalPayload, ['templateReference', 'TemplateReference']); + + if (! is_array($templateReference)) { + throw new \RuntimeException('Endpoint Security policy snapshot is missing templateReference and cannot be restored safely.'); + } + + $templateOutcome = $this->templateResolver->resolveTemplateReference($tenant, $templateReference, $graphOptions); + + if (! ($templateOutcome['success'] ?? false)) { + $reason = $templateOutcome['reason'] ?? 'Endpoint Security template is not available in the tenant.'; + + throw new \RuntimeException($reason); + } + + $resolvedTemplateId = $templateOutcome['template_id'] ?? null; + $resolvedReference = $templateOutcome['template_reference'] ?? $templateReference; + + if (! is_string($resolvedTemplateId) || $resolvedTemplateId === '') { + throw new \RuntimeException('Endpoint Security template could not be resolved (missing template id).'); + } + + if (is_array($resolvedReference) && $resolvedReference !== []) { + $originalPayload['templateReference'] = $resolvedReference; + } + + if ($settings === []) { + return $originalPayload; + } + + $definitions = $this->templateResolver->fetchTemplateSettingDefinitionIds($tenant, $resolvedTemplateId, $graphOptions); + + if (! ($definitions['success'] ?? false)) { + return $originalPayload; + } + + $templateDefinitionIds = $definitions['definition_ids'] ?? []; + + if (! is_array($templateDefinitionIds) || $templateDefinitionIds === []) { + return $originalPayload; + } + + $policyDefinitionIds = $this->templateResolver->extractSettingDefinitionIds($settings); + $missing = array_values(array_diff($policyDefinitionIds, $templateDefinitionIds)); + + if ($missing === []) { + return $originalPayload; + } + + $sample = implode(', ', array_slice($missing, 0, 5)); + $suffix = count($missing) > 5 ? sprintf(' (and %d more)', count($missing) - 5) : ''; + + throw new \RuntimeException(sprintf( + 'Endpoint Security settings do not match the resolved template (%s). Missing setting definitions: %s%s', + $resolvedTemplateId, + $sample, + $suffix, + )); + } + /** * @return array{attempted:bool,success:bool,policy_id:?string,response:?object} */ diff --git a/config/graph_contracts.php b/config/graph_contracts.php index ed0536f..b6c35a4 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -143,6 +143,19 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_whitelist' => [ + 'name', + 'description', + ], + 'update_map' => [ + 'displayName' => 'name', + ], + 'update_strip_keys' => [ + 'platforms', + 'technologies', + 'templateReference', + 'assignments', + ], 'member_hydration_strategy' => 'subresource_settings', 'subresources' => [ 'settings' => [ @@ -153,6 +166,13 @@ 'allowed_expand' => [], ], ], + 'settings_write' => [ + 'path_template' => 'deviceManagement/configurationPolicies/{id}/settings', + 'method' => 'POST', + 'bulk' => true, + 'body_shape' => 'collection', + 'fallback_body_shape' => 'wrapped', + ], // Assignments CRUD (standard Graph pattern) 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', @@ -514,6 +534,11 @@ 'update_method' => 'PATCH', 'id_field' => 'id', 'hydration' => 'properties', + 'update_strip_keys' => [ + 'isAssigned', + 'templateId', + 'isMigratingToConfigurationPolicy', + ], ], 'mobileApp' => [ 'resource' => 'deviceAppManagement/mobileApps', diff --git a/config/tenantpilot.php b/config/tenantpilot.php index e3d1ec9..1f6f205 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -192,7 +192,7 @@ 'platform' => 'windows', 'endpoint' => 'deviceManagement/configurationPolicies', 'backup' => 'full', - 'restore' => 'preview-only', + 'restore' => 'enabled', 'risk' => 'high', ], [ diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 38a6ce4..0c9e694 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -268,10 +268,16 @@ @if (! empty($item['graph_error_code']))
Code: {{ $item['graph_error_code'] }}
@endif - @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id'])) + @if (! empty($item['graph_request_id']) || ! empty($item['graph_client_request_id']) || ! empty($item['graph_method']) || ! empty($item['graph_path']))
Details
+ @if (! empty($item['graph_method'])) +
method: {{ $item['graph_method'] }}
+ @endif + @if (! empty($item['graph_path'])) +
path: {{ $item['graph_path'] }}
+ @endif @if (! empty($item['graph_request_id']))
request-id: {{ $item['graph_request_id'] }}
@endif diff --git a/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php new file mode 100644 index 0000000..c19edbc --- /dev/null +++ b/tests/Feature/EndpointSecurityIntentRestoreSanitizationTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + 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->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + 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, []); + } +} + +test('restore strips non-patchable fields from endpoint security intent updates', function () { + $client = new EndpointSecurityIntentRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'intent-1', + 'policy_type' => 'endpointSecurityIntent', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'intent-1', + '@odata.type' => '#microsoft.graph.deviceManagementIntent', + 'displayName' => 'SPO Account Protection', + 'description' => 'Demo', + 'isAssigned' => false, + 'templateId' => '0f2b5d70-d4e9-4156-8c16-1397eb6c54a5', + 'isMigratingToConfigurationPolicy' => false, + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + + $payload = $client->applyPolicyCalls[0]['payload'] ?? []; + expect($payload)->toBeArray(); + expect($payload)->toHaveKey('displayName'); + expect($payload)->toHaveKey('description'); + expect($payload)->not->toHaveKey('id'); + expect($payload)->not->toHaveKey('isAssigned'); + expect($payload)->not->toHaveKey('templateId'); + expect($payload)->not->toHaveKey('isMigratingToConfigurationPolicy'); +}); diff --git a/tests/Feature/EndpointSecurityPolicyRestore023Test.php b/tests/Feature/EndpointSecurityPolicyRestore023Test.php new file mode 100644 index 0000000..1a4821e --- /dev/null +++ b/tests/Feature/EndpointSecurityPolicyRestore023Test.php @@ -0,0 +1,265 @@ +}> */ + public array $applyPolicyCalls = []; + + /** @var array}> */ + public array $requestCalls = []; + + /** + * @param array $requestMap + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private readonly array $requestMap = [], + ) {} + + 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->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'options' => $options, + ]; + + foreach ($this->requestMap as $needle => $response) { + if (is_string($needle) && $needle !== '' && str_contains($path, $needle)) { + return $response; + } + } + + return new GraphResponse(true, []); + } +} + +test('restore executes endpoint security policy settings via settings endpoint', function () { + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, [])); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'template-1', + 'templateFamily' => 'endpointSecurityFirewall', + 'templateDisplayName' => 'Windows Firewall Rules', + 'templateDisplayVersion' => 'Version 1', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('completed'); + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('endpointSecurityPolicy'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('settings'); + + $settingsCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && str_contains($call['path'], '/settings')) + ->values(); + + expect($settingsCalls)->toHaveCount(1); + expect($settingsCalls[0]['path'])->toContain('deviceManagement/configurationPolicies/esp-1/settings'); + + $body = $settingsCalls[0]['options']['json'] ?? null; + expect($body)->toBeArray()->not->toBeEmpty(); + expect($body[0]['settingInstance']['settingDefinitionId'] ?? null) + ->toBe('device_vendor_msft_policy_config_defender_allowrealtimemonitoring'); +}); + +test('restore fails when endpoint security template is missing', function () { + $applyNotFound = new GraphResponse(false, ['error' => ['message' => 'Not found']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Not found', + ]); + + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient($applyNotFound, [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'platforms' => ['windows10'], + 'technologies' => ['endpointSecurity'], + 'templateReference' => [ + 'templateId' => 'missing-template', + ], + 'settings' => [ + [ + 'id' => 's1', + 'settingInstance' => [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance', + 'settingDefinitionId' => 'device_vendor_msft_policy_config_defender_allowrealtimemonitoring', + 'simpleSettingValue' => [ + 'value' => 1, + ], + ], + ], + ], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($run->status)->toBe('failed'); + + $createCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST' && $call['path'] === 'deviceManagement/configurationPolicies') + ->values(); + + expect($createCalls)->toHaveCount(0); +}); + +test('restore risk checks flag missing endpoint security templates as blocking', function () { + $templateNotFound = new GraphResponse(false, ['error' => ['message' => 'Template missing']], 404, [], [], [ + 'error_code' => 'NotFound', + 'error_message' => 'Template missing', + ]); + + $client = new EndpointSecurityRestoreGraphClient(new GraphResponse(true, []), [ + 'configurationPolicyTemplates' => $templateNotFound, + ]); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-missing', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-missing', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'templateReference' => [ + 'templateId' => 'missing-template', + 'templateFamily' => 'endpointSecurityFirewall', + ], + 'settings' => [], + ], + 'assignments' => null, + ]); + + $checker = app(RestoreRiskChecker::class); + $result = $checker->check( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + groupMapping: [], + ); + + $results = collect($result['results'] ?? []); + $templateCheck = $results->firstWhere('code', 'endpoint_security_templates'); + + expect($templateCheck)->not->toBeNull(); + expect($templateCheck['severity'] ?? null)->toBe('blocking'); +}); diff --git a/tests/Feature/PolicyTypes017Test.php b/tests/Feature/PolicyTypes017Test.php index 3c3813d..2d475d8 100644 --- a/tests/Feature/PolicyTypes017Test.php +++ b/tests/Feature/PolicyTypes017Test.php @@ -262,6 +262,6 @@ public function request(string $method, string $path, array $options = []): Grap $byType = collect($preview)->keyBy('policy_type'); expect($byType['mamAppConfiguration']['restore_mode'])->toBe('enabled'); - expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('preview-only'); + expect($byType['endpointSecurityPolicy']['restore_mode'])->toBe('enabled'); expect($byType['securityBaselinePolicy']['restore_mode'])->toBe('preview-only'); }); diff --git a/tests/Feature/RestoreGraphErrorMetadataTest.php b/tests/Feature/RestoreGraphErrorMetadataTest.php new file mode 100644 index 0000000..0222ca2 --- /dev/null +++ b/tests/Feature/RestoreGraphErrorMetadataTest.php @@ -0,0 +1,102 @@ +}> */ + public array $applyPolicyCalls = []; + + 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->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => "Resource not found for the segment 'endpointSecurityPolicy'.", + 'request_id' => 'req-1', + 'client_request_id' => 'client-1', + 'method' => 'PATCH', + 'path' => 'deviceManagement/endpointSecurityPolicy/esp-1', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +test('restore results include graph path and method on Graph failures', function () { + $client = new RestoreGraphErrorMetadataGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'esp-1', + 'policy_type' => 'endpointSecurityPolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'esp-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Endpoint Security Policy', + 'settings' => [], + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($run->status)->toBe('failed'); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['graph_method'] ?? null)->toBe('PATCH'); + expect($result['graph_path'] ?? null)->toBe('deviceManagement/endpointSecurityPolicy/esp-1'); +}); diff --git a/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php new file mode 100644 index 0000000..f66f7c6 --- /dev/null +++ b/tests/Feature/RestoreUnknownPolicyTypeSafetyTest.php @@ -0,0 +1,117 @@ +}> */ + public array $applyPolicyCalls = []; + + 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->applyPolicyCalls[] = [ + 'policyType' => $policyType, + 'policyId' => $policyId, + 'payload' => $payload, + 'options' => $options, + ]; + + return new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [], [], [ + 'error_code' => 'BadRequest', + 'error_message' => 'Bad request', + ]); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } +} + +beforeEach(function () { + $this->originalSupportedTypes = config('tenantpilot.supported_policy_types'); + $this->originalSecurityBaselineContract = config('graph_contracts.types.securityBaselinePolicy'); +}); + +afterEach(function () { + config()->set('tenantpilot.supported_policy_types', $this->originalSupportedTypes); + + if (is_array($this->originalSecurityBaselineContract)) { + config()->set('graph_contracts.types.securityBaselinePolicy', $this->originalSecurityBaselineContract); + } +}); + +test('restore skips security baseline policies when type metadata is missing', function () { + $client = new RestoreUnknownTypeGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + static fn (array $type): bool => ($type['type'] ?? null) !== 'securityBaselinePolicy' + )); + + config()->set('tenantpilot.supported_policy_types', $supported); + config()->set('graph_contracts.types.securityBaselinePolicy', []); + + $tenant = Tenant::factory()->create(); + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->for($tenant)->for($backupSet)->create([ + 'policy_id' => null, + 'policy_identifier' => 'baseline-1', + 'policy_type' => 'securityBaselinePolicy', + 'platform' => 'windows', + 'payload' => [ + 'id' => 'baseline-1', + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'name' => 'Security Baseline Policy', + ], + 'assignments' => null, + ]); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + ); + + expect($client->applyPolicyCalls)->toHaveCount(0); + + $result = $run->results[0] ?? null; + expect($result)->toBeArray(); + expect($result['status'] ?? null)->toBe('skipped'); + expect($result['restore_mode'] ?? null)->toBe('preview-only'); +}); diff --git a/tests/Unit/GraphClientEndpointResolutionTest.php b/tests/Unit/GraphClientEndpointResolutionTest.php index 3c76a8a..532b799 100644 --- a/tests/Unit/GraphClientEndpointResolutionTest.php +++ b/tests/Unit/GraphClientEndpointResolutionTest.php @@ -61,3 +61,40 @@ return str_contains($request->url(), '/beta/deviceAppManagement/targetedManagedAppConfigurations/A_1'); }); }); + +it('uses built-in endpoint mapping for endpoint security policies when config is missing', function () { + config()->set('graph_contracts.types.endpointSecurityPolicy', []); + config()->set('tenantpilot.foundation_types', []); + + Http::fake([ + 'https://login.microsoftonline.com/*' => Http::response([ + 'access_token' => 'fake-token', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/*' => Http::response(['id' => 'E_1'], 200), + ]); + + $client = new MicrosoftGraphClient( + logger: app(GraphLogger::class), + contracts: app(GraphContractRegistry::class), + ); + + $client->applyPolicy( + policyType: 'endpointSecurityPolicy', + policyId: 'E_1', + payload: ['name' => 'Test'], + options: ['tenant' => 'tenant', 'client_id' => 'client', 'client_secret' => 'secret'], + ); + + Http::assertSent(function (Request $request) { + if (! str_contains($request->url(), 'graph.microsoft.com')) { + return false; + } + + if (! str_contains($request->url(), '/beta/deviceManagement/configurationPolicies/E_1')) { + return false; + } + + return ! str_contains($request->url(), '/beta/deviceManagement/endpointSecurityPolicy/E_1'); + }); +}); From 69d98e925c13f89efb372101e1b57554cc994b51 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 00:38:54 +0000 Subject: [PATCH 10/18] feat/018-driver-updates-wufb (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tenants: Tenant anlegen/öffnen → tenant_id, app_client_id, app_client_secret setzen → Make current (wichtig). Inventory → Policies: oben Sync from Intune. In der Tabelle nach Type = “Driver Updates (Windows)” (windowsDriverUpdateProfile) filtern und Policy öffnen. Auf der Policy: Settings-Tab prüfen (Block „Driver Update Profile“), dann Capture snapshot klicken und unter Versions die Version ansehen. Restore-Test (nur im Test-Tenant!): Version öffnen → Restore to Intune erst als Dry-run, dann Execute; danach unter Backups & Restore → Restore Runs Ergebnis prüfen (soll graph_path mit deviceManagement/windowsDriverUpdateProfiles/... zeigen). Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/27 --- .specify/memory/constitution.md | 63 +++---- .specify/plan.md | 26 +-- .specify/spec.md | 164 ++++++++++++++++-- .specify/tasks.md | 16 +- app/Providers/AppServiceProvider.php | 2 + .../WindowsDriverUpdateProfileNormalizer.php | 125 +++++++++++++ config/graph_contracts.php | 36 ++++ config/tenantpilot.php | 10 ++ .../checklists/requirements.md | 14 ++ specs/018-driver-updates-wufb/plan.md | 24 +++ specs/018-driver-updates-wufb/spec.md | 79 +++++++++ specs/018-driver-updates-wufb/tasks.md | 32 ++++ .../checklists/requirements.md | 17 +- specs/023-endpoint-security-restore/plan.md | 3 +- specs/023-endpoint-security-restore/spec.md | 4 +- specs/023-endpoint-security-restore/tasks.md | 35 ++-- tests/Feature/Filament/RestorePreviewTest.php | 71 ++++++++ .../WindowsUpdateProfilesRestoreTest.php | 85 +++++++++ tests/Feature/PolicySyncServiceTest.php | 46 ++++- tests/Unit/PolicySnapshotServiceTest.php | 56 ++++++ ...ndowsDriverUpdateProfileNormalizerTest.php | 38 ++++ 21 files changed, 846 insertions(+), 100 deletions(-) create mode 100644 app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php create mode 100644 specs/018-driver-updates-wufb/checklists/requirements.md create mode 100644 specs/018-driver-updates-wufb/plan.md create mode 100644 specs/018-driver-updates-wufb/spec.md create mode 100644 specs/018-driver-updates-wufb/tasks.md create mode 100644 tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a4670ff..8670192 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,50 +1,35 @@ -# [PROJECT_NAME] Constitution - +# TenantPilot Constitution ## Core Principles -### [PRINCIPLE_1_NAME] - -[PRINCIPLE_1_DESCRIPTION] - +### Safety-First Restore +- Any destructive action MUST support preview/dry-run, explicit confirmation, and a clear pre-execution summary. +- High-risk policy types default to `preview-only` restore unless explicitly enabled by a feature spec + tests + checklist. +- Restore must be defensive: validate inputs, detect conflicts, allow selective restore, and record outcomes per item. -### [PRINCIPLE_2_NAME] - -[PRINCIPLE_2_DESCRIPTION] - +### Auditability & Tenant Isolation +- Every operation is tenant-scoped and MUST write an audit log entry (no secrets, no tokens). +- Snapshots are immutable JSONB and MUST remain reproducible (who/when/what/source tenant). -### [PRINCIPLE_3_NAME] - -[PRINCIPLE_3_DESCRIPTION] - +### Graph Abstraction & Contracts +- All Microsoft Graph calls MUST go through `GraphClientInterface`. +- Contract assumptions are config-driven (`config/graph_contracts.php`); do not hardcode endpoints in feature code. +- Unknown/missing policy types MUST fail safe (preview-only / no Graph calls) rather than calling `deviceManagement/{type}`. -### [PRINCIPLE_4_NAME] - -[PRINCIPLE_4_DESCRIPTION] - +### Least Privilege +- Prefer least-privilege roles/scopes; surface warnings when higher privileges are selected. +- Never store secrets in code/config; never log credentials or tokens. -### [PRINCIPLE_5_NAME] - -[PRINCIPLE_5_DESCRIPTION] - +### Spec-First Workflow +- For any feature that changes runtime behavior, include or update `specs/-/` with `spec.md`, `plan.md`, `tasks.md`, and `checklists/requirements.md`. +- New work branches from `dev` using `feat/-` (spec + code in the same PR). -## [SECTION_2_NAME] - - -[SECTION_2_CONTENT] - - -## [SECTION_3_NAME] - - -[SECTION_3_CONTENT] - +## Quality Gates +- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`. +- Run `./vendor/bin/pint --dirty` before finalizing. ## Governance - +- This constitution applies across the repo. Feature specs may add stricter constraints but not weaker ones. +- Restore semantics changes require: spec update, checklist update, and tests proving safety. -[GOVERNANCE_RULES] - - -**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] - +**Version**: 1.0.0 | **Ratified**: 2026-01-03 | **Last Amended**: 2026-01-03 diff --git a/.specify/plan.md b/.specify/plan.md index d5d5e5b..eb4bfe3 100644 --- a/.specify/plan.md +++ b/.specify/plan.md @@ -1,16 +1,16 @@ # Implementation Plan: TenantPilot v1 -**Branch**: `tenantpilot-v1` -**Date**: 2025-12-12 -**Spec Source**: `.specify/spec.md` (scope/restore matrix unchanged) +**Branch**: `dev` +**Date**: 2026-01-03 +**Spec Source**: `.specify/spec.md` (scope/restore matrix is config-driven) ## Summary -TenantPilot v1 already delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, and Highlander enforcement. Remaining priority work is the delegated Intune RBAC onboarding wizard (US7) and afterwards the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). +TenantPilot v1 delivers tenant-scoped Intune inventory, immutable backups, version history with diffs, defensive restore flows, tenant setup, permissions/health, settings normalization/display, Highlander enforcement, the delegated RBAC onboarding wizard (US7), and the Graph Contract Registry & Drift Guard (US8). All Graph calls stay behind the abstraction with audit logging; snapshots remain JSONB with safety gates (preview-only for high-risk types). ## Status Snapshot (tasks.md is source of truth) -- **Done**: US1 inventory, US2 backups, US3 versions/diffs, US4 restore preview/exec, scope config, soft-deletes/housekeeping, Highlander single current tenant, tenant setup & verify (US6), permissions/health overview (US6), table ActionGroup UX, settings normalization/display (US1b), Dokploy/Sail runbooks. -- **Next up**: **US7** Intune RBAC onboarding wizard (delegated, synchronous Filament flow). -- **Upcoming**: **US8** Graph Contract Registry & Drift Guard (contract registry, type-family handling, verification command, fallback strategies). +- **Done**: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, permissions/health, housekeeping/UX, ops). +- **Open**: T167 (optional) CLI/Job for CHECK/REPORT only (no grant). +- **Next up**: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/`. ## Technical Baseline - Laravel 12, Filament 4, PHP 8.4; Sail-first with PostgreSQL. @@ -28,10 +28,12 @@ ## Completed Workstreams (no new action needed) - **US6 Tenant Setup & Highlander (Phases 8 & 12)**: Tenant CRUD/verify, INTUNE_TENANT_ID override, `is_current` unique enforcement, “Make current” action, block deactivated tenants. - **US6 Permissions/Health (Phase 9)**: Required permissions list, compare/check service, Verify action updates status and audit, permissions panel in Tenant detail. - **US1b Settings Display (Phase 13)**: PolicyNormalizer + SnapshotValidator, warnings for malformed snapshots, normalized settings and pretty JSON on policy/version detail, list badges, README section. +- **US7 RBAC Wizard (Phase 14)**: Delegated, synchronous onboarding wizard with post-verify canary checks and audit trail. +- **US8 Graph Contracts & Drift Guard (Phase 15)**: Config-driven contract registry, type-family handling, capability downgrade fallbacks, and a drift-check command. - **Housekeeping/UX (Phases 10–12)**: Soft/force deletes for tenants/backups/versions/restore runs with guards; table actions in ActionGroup per UX guideline. - **Ops (Phase 7)**: Sail runbook and Dokploy staging→prod guidance captured. -## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) +## Completed: US7 Intune RBAC Onboarding Wizard (Phase 14) - Objectives: deliver delegated, tenant-scoped wizard that safely converges the Intune RBAC state for the configured service principal; fully audited, idempotent, least-privilege by default. - Scope alignment: FR-023–FR-030, constitution (Safety-First, Auditability, Tenant-Aware, Graph Abstraction). No secret/token persistence; delegated tokens stay request-local and are not stored in DB/cache. @@ -56,7 +58,7 @@ ## Execution Plan: US7 Intune RBAC Onboarding Wizard (Phase 14) - Health integration: Verify reflects RBAC status and prompts to run wizard when missing. - Deployment/ops: no new env vars; ensure migrations for tenant RBAC columns are applied; run targeted tests `php artisan test tests/Unit/RbacOnboardingServiceTest.php tests/Feature/Filament/TenantRbacWizardTest.php`; Pint on touched files. -## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) +## Completed: US8 Graph Contract Registry & Drift Guard (Phase 15) - Objectives: centralize Graph contract assumptions per supported type/endpoint and provide drift detection + safe fallbacks so preview/restore remain stable on Graph shape/capability changes. - Scope alignment: FR-031–FR-034 (spec), constitution (Safety-First, Auditability, Graph Abstraction, Tenant-Aware). @@ -74,7 +76,7 @@ ## Upcoming: US8 Graph Contract Registry & Drift Guard (Phase 15) - Testing outline: unit for registry lookups/type-family matching/fallback selection; integration/Pest to simulate capability errors and ensure downgrade path + correct routing for derived types. ## Testing & Quality Gates -- Continue using targeted Pest runs per change set; add/extend tests for US7 wizard now, and for US8 contracts when implemented. +- Continue using targeted Pest runs per change set; add/extend tests when RBAC/contract behavior changes. - Run Pint on touched files before finalizing. - Maintain tenant isolation, audit logging, and restore safety gates; validate snapshot shape and type-family compatibility prior to restore execution. @@ -83,6 +85,6 @@ ### Restore Safety Gate - Restore preview MAY still render details + warnings for out-of-family snapshots, but MUST NOT offer an apply action. ## Coordination -- Update `.specify/tasks.md` to reflect progress on US7 wizard and future US8 contract tasks; no new entities or scope changes introduced here. +- Keep `.specify/tasks.md` and per-feature specs under `specs/` aligned with implementation changes. - Stage validation required before production for any migration or restore-impacting change. -- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). \ No newline at end of file +- Keep Graph integration behind abstraction; no secrets in logs; follow existing UX patterns (ActionGroup, warnings for risky ops). diff --git a/.specify/spec.md b/.specify/spec.md index 6ba9f44..227841a 100644 --- a/.specify/spec.md +++ b/.specify/spec.md @@ -1,20 +1,50 @@ # Feature Specification: TenantPilot v1 -**Feature Branch**: `tenantpilot-v1` +**Feature Branch**: `dev` **Created**: 2025-12-10 -**Status**: Draft +**Status**: Active +**Last Updated**: 2026-01-03 **Input**: TenantPilot v1 scope covering Intune configuration inventory (config, compliance, scripts, apps, conditional access, endpoint security, enrollment/autopilot, RBAC), backup, version history, and defensive restore for Intune administrators. ## Scope ```yaml scope: - description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können." + description: "v1 muss folgende Intune-Objekttypen inventarisieren, sichern und – je nach Risikoklasse – wiederherstellen können. Single Source of Truth: config/tenantpilot.php + config/graph_contracts.php." supported_types: - key: deviceConfiguration name: "Device Configuration" graph_resource: "deviceManagement/deviceConfigurations" - notes: "Inklusive Custom OMA-URI, Administrative Templates und Settings Catalog." + filter: "not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')" + notes: "Standard Device Config inkl. Custom OMA-URI; excludes WUfB Update Rings." + + - key: groupPolicyConfiguration + name: "Administrative Templates" + graph_resource: "deviceManagement/groupPolicyConfigurations" + notes: "Administrative Templates (Group Policy)." + + - key: settingsCatalogPolicy + name: "Settings Catalog Policy" + graph_resource: "deviceManagement/configurationPolicies" + notes: "Settings Catalog policies; settings are hydrated from the /settings subresource." + + - key: windowsUpdateRing + name: "Software Update Ring" + graph_resource: "deviceManagement/deviceConfigurations" + filter: "isof('microsoft.graph.windowsUpdateForBusinessConfiguration')" + notes: "Windows Update for Business (WUfB) update rings." + + - key: windowsFeatureUpdateProfile + name: "Feature Updates (Windows)" + graph_resource: "deviceManagement/windowsFeatureUpdateProfiles" + + - key: windowsQualityUpdateProfile + name: "Quality Updates (Windows)" + graph_resource: "deviceManagement/windowsQualityUpdateProfiles" + + - key: windowsDriverUpdateProfile + name: "Driver Updates (Windows)" + graph_resource: "deviceManagement/windowsDriverUpdateProfiles" - key: deviceCompliancePolicy name: "Device Compliance" @@ -25,6 +55,16 @@ ## Scope graph_resource: "deviceAppManagement/managedAppPolicies" notes: "iOS und Android Managed App Protection." + - key: mamAppConfiguration + name: "App Configuration (MAM)" + graph_resource: "deviceAppManagement/targetedManagedAppConfigurations" + notes: "App configuration targeting managed apps (MAM)." + + - key: managedDeviceAppConfiguration + name: "App Configuration (Device)" + graph_resource: "deviceAppManagement/mobileAppConfigurations" + notes: "Managed device app configuration profiles." + - key: conditionalAccessPolicy name: "Conditional Access" graph_resource: "identity/conditionalAccess/policies" @@ -35,6 +75,14 @@ ## Scope graph_resource: "deviceManagement/deviceManagementScripts" notes: "scriptContent wird beim Backup base64-decoded gespeichert und beim Restore wieder encoded (vgl. FR-020)." + - key: deviceShellScript + name: "macOS Shell Scripts" + graph_resource: "deviceManagement/deviceShellScripts" + + - key: deviceHealthScript + name: "Proactive Remediations" + graph_resource: "deviceManagement/deviceHealthScripts" + - key: enrollmentRestriction name: "Enrollment Restrictions" graph_resource: "deviceManagement/deviceEnrollmentConfigurations" @@ -46,22 +94,40 @@ ## Scope - key: windowsEnrollmentStatusPage name: "Enrollment Status Page (ESP)" graph_resource: "deviceManagement/deviceEnrollmentConfigurations" - filter: "odata.type eq '#microsoft.graph.windows10EnrollmentCompletionPageConfiguration'" + notes: "Filtered to #microsoft.graph.windows10EnrollmentCompletionPageConfiguration." - key: endpointSecurityIntent name: "Endpoint Security Intents" graph_resource: "deviceManagement/intents" notes: "Account Protection, Disk Encryption etc.; Zuordnung über bekannte Templates." + - key: endpointSecurityPolicy + name: "Endpoint Security Policies" + graph_resource: "deviceManagement/configurationPolicies" + notes: "Configuration policies classified via technologies/templateReference; restore execution enabled with template validation (Feature 023)." + + - key: securityBaselinePolicy + name: "Security Baselines" + graph_resource: "deviceManagement/configurationPolicies" + notes: "High risk; v1 restore stays preview-only." + - key: mobileApp name: "Applications (Metadata only)" graph_resource: "deviceAppManagement/mobileApps" notes: "Backup nur von Metadaten/Zuweisungen (kein Binary-Download in v1)." - - key: settingsCatalogPolicy - name: "Settings Catalog Policy" - graph_resource: "deviceManagement/configurationPolicies" - notes: "Intune Settings Catalog Policies liegen NICHT unter deviceConfigurations, sondern unter configurationPolicies. v1 behandelt sie als eigenen Typ." + foundation_types: + - key: assignmentFilter + name: "Assignment Filter" + graph_resource: "deviceManagement/assignmentFilters" + + - key: roleScopeTag + name: "Scope Tag" + graph_resource: "deviceManagement/roleScopeTags" + + - key: notificationMessageTemplate + name: "Notification Message Template" + graph_resource: "deviceManagement/notificationMessageTemplates" restore_matrix: deviceConfiguration: @@ -70,6 +136,37 @@ ## Scope risk: medium notes: "Standard-Case für Backup+Restore; starke Preview/Audit Pflicht." + groupPolicyConfiguration: + backup: full + restore: enabled + risk: medium + + settingsCatalogPolicy: + backup: full + restore: enabled + risk: medium + notes: "Settings are applied via configurationPolicies/{id}/settings; capability fallbacks may require manual follow-up." + + windowsUpdateRing: + backup: full + restore: enabled + risk: medium-high + + windowsFeatureUpdateProfile: + backup: full + restore: enabled + risk: high + + windowsQualityUpdateProfile: + backup: full + restore: enabled + risk: high + + windowsDriverUpdateProfile: + backup: full + restore: enabled + risk: high + deviceCompliancePolicy: backup: full restore: enabled @@ -82,6 +179,16 @@ ## Scope risk: medium-high notes: "MAM-Änderungen wirken auf Datenzugriff in Apps; Preview und Diff wichtig." + mamAppConfiguration: + backup: full + restore: enabled + risk: medium-high + + managedDeviceAppConfiguration: + backup: full + restore: enabled + risk: medium-high + conditionalAccessPolicy: backup: full restore: preview-only @@ -94,6 +201,16 @@ ## Scope risk: medium notes: "Script-Inhalt und Einstellungen werden gesichert; Decode/Encode beachten." + deviceShellScript: + backup: full + restore: enabled + risk: medium + + deviceHealthScript: + backup: full + restore: enabled + risk: medium + enrollmentRestriction: backup: full restore: preview-only @@ -118,17 +235,38 @@ ## Scope risk: high notes: "Security-relevante Einstellungen (z. B. Credential Guard); Preview + klare Konflikt-Warnungen nötig." - settingsCatalogPolicy: + endpointSecurityPolicy: backup: full - restore: enableds - risk: medium - notes: "Settings Catalog Policies sind Standard-Config-Policies (Settings Catalog). Preview/Audit Pflicht; Restore automatisierbar." + restore: enabled + risk: high + notes: "Enabled with template validation (Feature 023)." + + securityBaselinePolicy: + backup: full + restore: preview-only + risk: high + notes: "High risk; preview-only by default." mobileApp: backup: metadata-only restore: enabled risk: low-medium notes: "Nur Metadaten/Zuweisungen; kein Binary; Restore setzt Konfigurationen/Zuweisungen wieder." + + assignmentFilter: + backup: full + restore: enabled + risk: low + + roleScopeTag: + backup: full + restore: enabled + risk: low + + notificationMessageTemplate: + backup: full + restore: enabled + risk: low ``` ## User Scenarios & Testing *(mandatory)* diff --git a/.specify/tasks.md b/.specify/tasks.md index d369dae..701dd6f 100644 --- a/.specify/tasks.md +++ b/.specify/tasks.md @@ -8,9 +8,9 @@ # Tasks: TenantPilot v1 **Prerequisites**: plan.md (complete), spec.md (complete) **Status snapshot** -- Done: Phases 1–13 (US1–US4, Settings normalization/display, Highlander, US6 permissions/health, housekeeping/UX, ops) -- Next up: Phase 14 (US7) delegated Intune RBAC onboarding wizard (synchronous) -- Upcoming: Phase 15 (US8) Graph Contract Registry & Drift Guard +- Done: Phases 1–15 (US1–US8, Settings Catalog hydration/display, restore rerun, Highlander, US6 permissions/health, housekeeping/UX, ops) +- Open: T167 (optional) CLI/Job for CHECK/REPORT only (no grant) +- Next up: Feature 018 (Driver Updates / WUfB add-on) in `specs/018-driver-updates-wufb/` --- @@ -188,7 +188,7 @@ ## Acceptance Criteria - Restore von `settingsCatalogPolicy` scheitert nicht mehr an `Platforms`. - Results zeigen bei Fehlern weiterhin request-id/client-request-id (bleibt wie T177). -- [ ] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display +- [x] T179 [US1b][Scope][settingsCatalogPolicy] Hydrate Settings Catalog “Configuration settings” for snapshots + normalized display - **Goal:** Für `settingsCatalogPolicy` sollen die **Configuration settings** (wie im Intune Portal unter *Configuration settings*) im System sichtbar sein: - in **Policy Version Raw JSON** enthalten @@ -278,7 +278,7 @@ ## Verification -- [ ] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot +- [x] T180 [US1b][Bug][settingsCatalogPolicy] Hydrate Settings Catalog settings in Version capture + Policy detail uses hydrated snapshot - **Goal:** `settingsCatalogPolicy` soll die *Configuration settings* nicht nur in Backups, sondern auch in **Policy Versions** enthalten, damit **Policy Detail**, Diff/Preview/Restore auf den echten Settings basieren. - **Why:** Aktuell hydriert nur `BackupService`, aber Policy Detail/Versions zeigen weiterhin nur Base-Metadaten. @@ -610,7 +610,7 @@ ## Acceptance Criteria -- [ ]T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) +- [x] T185 [UX][US1b][settingsCatalogPolicy] Make Settings Catalog settings readable (label/value parsing + table ergonomics) - **Goal:** Settings Catalog Policies sollen im Policy/Version Detail **für Admins lesbar** sein, ohne dass wir “alle Settings kennen müssen”. - Tabelle zeigt **sprechende Bezeichnung** + **kompakte Werte** @@ -699,7 +699,7 @@ ## Acceptance Criteria - **Readable Setting name** (not a cut-off vendor string) - **Readable Value preview** (True/False/12/etc.) -- [ ] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings +- [x] T186 [US4][Bugfix][settingsCatalogPolicy] Fix settings_apply payload typing (@odata.type) + body shape for configurationPolicies/{id}/settings **Goal:** Restore für `settingsCatalogPolicy` soll Settings zuverlässig anwenden können, ohne ModelValidationFailure wegen fehlender/entfernter `@odata.type`. @@ -787,7 +787,7 @@ ### Implementation for User Story 4 - [x] T023 [US4] Implement restore service with preview/dry-run and selective item application in `app/Services/Intune/RestoreService.php`, integrating Graph adapter and conflict detection. - [x] T024 [US4] Add Filament restore UI (wizard or pages) showing preview, warnings, and confirmation gate in `app/Filament/Resources/RestoreRunResource.php`. - [x] T025 [US4] Record restore run lifecycle (start, per-item result, completion) and audit events in `restore_runs` and `audit_logs`. -- [ ] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. +- [x] T156 [US4][UX] Add “Rerun” action to RestoreRun row actions (ActionGroup): creates a new RestoreRun cloned from selected run (same backup_set_id, same selected items, same dry_run flag), enforces same safety gates/confirmations as original execution path, writes audit event restore_run.rerun_created with source_restore_run_id. ## Phase 7: User Story 5 - Operational readiness and environments (Priority: P2) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2bb90ea..b5c7c32 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\WindowsDriverUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer; @@ -49,6 +50,7 @@ public function register(): void ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, + WindowsDriverUpdateProfileNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, WindowsUpdateRingNormalizer::class, diff --git a/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php new file mode 100644 index 0000000..0bd657e --- /dev/null +++ b/app/Services/Intune/WindowsDriverUpdateProfileNormalizer.php @@ -0,0 +1,125 @@ +>, settings_table?: array, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->defaultNormalizer->normalize($snapshot, $policyType, $platform); + + if ($snapshot === []) { + return $normalized; + } + + $block = $this->buildDriverUpdateBlock($snapshot); + + if ($block !== null) { + $normalized['settings'][] = $block; + $normalized['settings'] = array_values(array_filter($normalized['settings'])); + } + + return $normalized; + } + + /** + * @return array + */ + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = $snapshot ?? []; + $normalized = $this->normalize($snapshot, $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + private function buildDriverUpdateBlock(array $snapshot): ?array + { + $entries = []; + + $displayName = Arr::get($snapshot, 'displayName'); + + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => 'Name', 'value' => $displayName]; + } + + $approvalType = Arr::get($snapshot, 'approvalType'); + + if (is_string($approvalType) && $approvalType !== '') { + $entries[] = ['key' => 'Approval type', 'value' => $approvalType]; + } + + $deferral = Arr::get($snapshot, 'deploymentDeferralInDays'); + + if (is_int($deferral) || (is_numeric($deferral) && (string) (int) $deferral === (string) $deferral)) { + $entries[] = ['key' => 'Deployment deferral (days)', 'value' => (int) $deferral]; + } + + $deviceReporting = Arr::get($snapshot, 'deviceReporting'); + + if (is_int($deviceReporting) || (is_numeric($deviceReporting) && (string) (int) $deviceReporting === (string) $deviceReporting)) { + $entries[] = ['key' => 'Devices reporting', 'value' => (int) $deviceReporting]; + } + + $newUpdates = Arr::get($snapshot, 'newUpdates'); + + if (is_int($newUpdates) || (is_numeric($newUpdates) && (string) (int) $newUpdates === (string) $newUpdates)) { + $entries[] = ['key' => 'New driver updates', 'value' => (int) $newUpdates]; + } + + $inventorySyncStatus = Arr::get($snapshot, 'inventorySyncStatus'); + + if (is_array($inventorySyncStatus)) { + $state = Arr::get($inventorySyncStatus, 'driverInventorySyncState'); + + if (is_string($state) && $state !== '') { + $entries[] = ['key' => 'Inventory sync state', 'value' => $state]; + } + + $lastSuccessful = $this->formatDateTime(Arr::get($inventorySyncStatus, 'lastSuccessfulSyncDateTime')); + + if ($lastSuccessful !== null) { + $entries[] = ['key' => 'Last successful inventory sync', 'value' => $lastSuccessful]; + } + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Driver Update Profile', + 'entries' => $entries, + ]; + } + + private function formatDateTime(mixed $value): ?string + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return CarbonImmutable::parse($value)->toDateTimeString(); + } catch (\Throwable) { + return $value; + } + } +} diff --git a/config/graph_contracts.php b/config/graph_contracts.php index b6c35a4..674e824 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -296,6 +296,42 @@ 'assignments_delete_path' => '/deviceManagement/windowsQualityUpdateProfiles/{id}/assignments/{assignmentId}', 'assignments_delete_method' => 'DELETE', ], + 'windowsDriverUpdateProfile' => [ + 'resource' => 'deviceManagement/windowsDriverUpdateProfiles', + 'allowed_select' => [ + 'id', + 'displayName', + 'description', + '@odata.type', + 'createdDateTime', + 'lastModifiedDateTime', + 'approvalType', + 'deploymentDeferralInDays', + 'roleScopeTagIds', + ], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.windowsDriverUpdateProfile', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'deviceReporting', + 'newUpdates', + 'inventorySyncStatus', + ], + 'assignments_list_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_update_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 1f6f205..d222293 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -64,6 +64,16 @@ 'restore' => 'enabled', 'risk' => 'high', ], + [ + 'type' => 'windowsDriverUpdateProfile', + 'label' => 'Driver Updates (Windows)', + 'category' => 'Update Management', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/windowsDriverUpdateProfiles', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'high', + ], [ 'type' => 'deviceCompliancePolicy', 'label' => 'Device Compliance', diff --git a/specs/018-driver-updates-wufb/checklists/requirements.md b/specs/018-driver-updates-wufb/checklists/requirements.md new file mode 100644 index 0000000..d6c149e --- /dev/null +++ b/specs/018-driver-updates-wufb/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (018) + +**Created**: 2026-01-03 +**Feature**: [spec.md](../spec.md) + +- [x] `windowsDriverUpdateProfile` is added to `config/tenantpilot.php` (metadata, endpoint, backup/restore mode, risk). +- [x] Graph contract exists in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- [x] Sync lists and stores driver update profiles in the Policies inventory. +- [x] Snapshot capture stores a complete payload for backups and versions. +- [x] Restore preview is available and respects the configured restore mode. +- [x] Restore execution applies only patchable properties and records audit logs. +- [x] Normalized settings view is readable for admins (no raw-only UX). +- [x] Pest tests cover sync + snapshot + restore + normalized display. +- [x] Pint run (`./vendor/bin/pint --dirty`) on touched files. diff --git a/specs/018-driver-updates-wufb/plan.md b/specs/018-driver-updates-wufb/plan.md new file mode 100644 index 0000000..1b26dda --- /dev/null +++ b/specs/018-driver-updates-wufb/plan.md @@ -0,0 +1,24 @@ +# Plan: Driver Updates (WUfB Add-on) (018) + +**Branch**: `feat/018-driver-updates-wufb` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md) + +## Goal +Add first-class support for Windows Driver Update profiles (`windowsDriverUpdateProfile`) across inventory, backup/version snapshots, restore (preview + execution), and normalized display. + +## Approach +1. Confirm Graph API details for driver update profiles (resource path, `@odata.type`, patchable properties, assignment endpoints). +2. Add type metadata to `config/tenantpilot.php` (category, endpoint, backup/restore mode, risk). +3. Add Graph contract entry in `config/graph_contracts.php` (resource, type family, create/update methods, assignments). +4. Ensure sync lists and stores these policies (config-driven loop) and add a targeted sync test. +5. Ensure snapshots capture the complete payload and add tests for version/backup capture. +6. Implement restore apply via contract-driven sanitization; add failure-safe behavior and tests. +7. Add a normalizer for readable UI output; add tests for normalized display. +8. Run Pint and targeted tests. + +## Decisions / Notes +- Default to contract-driven restore semantics; avoid bespoke Graph calls unless strictly required. +- If Graph rejects PATCH due to read-only fields, extend `update_strip_keys` for this type (do not loosen safety). +- Keep restore risk high; require clear preview and audit trail. + diff --git a/specs/018-driver-updates-wufb/spec.md b/specs/018-driver-updates-wufb/spec.md new file mode 100644 index 0000000..5ef2dcc --- /dev/null +++ b/specs/018-driver-updates-wufb/spec.md @@ -0,0 +1,79 @@ +# Feature Specification: Driver Updates (WUfB Add-on) (018) + +**Feature Branch**: `feat/018-driver-updates-wufb` +**Created**: 2026-01-03 +**Status**: Implemented +**Priority**: P1 + +## Context +TenantPilot already covers core Windows Update for Business (WUfB) objects like: +- Update Rings (`windowsUpdateRing`) +- Feature Update Profiles (`windowsFeatureUpdateProfile`) +- Quality Update Profiles (`windowsQualityUpdateProfile`) + +This feature adds **Windows Driver Updates** coverage to the same Update Management area so driver rollout configuration can be inventoried, snapshotted, diffed, and restored safely. + +## In Scope +- New policy type: `windowsDriverUpdateProfile` +- Inventory/sync: list driver update profiles from Microsoft Graph and store them as policies. +- Snapshot capture: full snapshot of the profile payload (and assignments where supported). +- Restore: + - Preview/dry-run with diff + risk checks. + - Execution (PATCH/POST) as allowed by Graph, with audit logging. +- UI: normalized settings display (readable, admin-focused). + +## Out of Scope (v1) +- Per-driver approval workflows / driver inventory insights. +- Advanced reporting on driver compliance. +- Partial per-setting restore. + +## Graph API Details (confirmed) +- **Resource**: `deviceManagement/windowsDriverUpdateProfiles` +- **@odata.type**: `#microsoft.graph.windowsDriverUpdateProfile` +- **Patchable fields**: `displayName`, `description`, `approvalType`, `deploymentDeferralInDays`, `roleScopeTagIds` +- **Read-only fields (strip on PATCH)**: `deviceReporting`, `newUpdates`, `inventorySyncStatus`, `createdDateTime`, `lastModifiedDateTime` +- **Assignments**: + - list: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments` + - assign action: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assign` + - update/delete: `/deviceManagement/windowsDriverUpdateProfiles/{id}/assignments/{assignmentId}` + +## User Scenarios & Testing + +### User Story 1 — Inventory + readable view (P1) +As an admin, I can see Windows Driver Update profiles in the Policies list and view their configuration in a readable way. + +**Acceptance** +1. Driver update profiles appear in the policy inventory with the correct type and category. +2. Policy detail shows a normalized settings table (not only raw JSON). +3. Policy Versions render “Normalized settings” consistently. + +### User Story 2 — Snapshot capture (P1) +As an admin, when I capture a version or add a driver update profile to a backup set, the snapshot contains all relevant settings. + +**Acceptance** +1. Snapshot stores the full Graph payload in JSON (immutable). +2. Any non-patchable/read-only properties are still preserved in the snapshot (but not sent on restore). + +### User Story 3 — Restore preview + execution (P1) +As an admin, I can restore a driver update profile from a snapshot with a clear preview and safe execution. + +**Acceptance** +1. Preview shows what would change and blocks if risk checks fail. +2. Execution applies only patchable properties (contract-driven sanitization). +3. Restore results include Graph error details (request-id, client-request-id, path/method) on failure. + +## Requirements + +### Functional Requirements +- **FR-001**: Add `windowsDriverUpdateProfile` to `config/tenantpilot.php` with category “Update Management”. +- **FR-002**: Add Graph contract entry for `windowsDriverUpdateProfile` in `config/graph_contracts.php` (resource, type family, create/update methods, assignments paths). +- **FR-003**: Ensure `PolicySyncService` syncs driver update profiles via config-driven type list. +- **FR-004**: Ensure `PolicySnapshotService` captures a complete payload for this type. +- **FR-005**: Ensure `RestoreService` applies snapshots using contract-driven sanitization and audit logging. +- **FR-006**: Add normalized display support for the key driver update profile fields. +- **FR-007**: Add automated Pest tests for sync + snapshot + restore preview/execution. + +### Non-Functional Requirements +- **NFR-001**: Preserve tenant isolation and least privilege. +- **NFR-002**: Keep restore safe-by-default (preview/confirmation/audit). +- **NFR-003**: No new external services or dependencies. diff --git a/specs/018-driver-updates-wufb/tasks.md b/specs/018-driver-updates-wufb/tasks.md new file mode 100644 index 0000000..19bf842 --- /dev/null +++ b/specs/018-driver-updates-wufb/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Driver Updates (WUfB Add-on) (018) + +**Branch**: `feat/018-driver-updates-wufb` +**Date**: 2026-01-03 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create/confirm spec, plan, tasks, checklist. + +## Phase 2: Research & Design +- [x] T002 Verify Graph resource + `@odata.type` for driver update profiles. +- [x] T003 Verify PATCHable fields and define `update_strip_keys` / `update_whitelist`. +- [x] T004 Verify assignment endpoints (`/assignments`, `/assign`) for this resource. +- [x] T005 Decide restore mode (`enabled` vs `preview-only`) based on risk + patchability. + +## Phase 3: Tests (TDD) +- [x] T006 Add sync test ensuring `windowsDriverUpdateProfile` policies are imported and typed correctly. +- [x] T007 Add snapshot/version capture test asserting full payload is stored. +- [x] T008 Add restore preview test for this type (entries + restore_mode shown). +- [x] T009 Add restore execution test asserting only patchable properties are sent and failures are reported with Graph metadata. +- [x] T010 Add normalized display test for key fields. + +## Phase 4: Implementation +- [x] T011 Add `windowsDriverUpdateProfile` to `config/tenantpilot.php`. +- [x] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [x] T013 Implement any required snapshot hydration (if Graph uses subresources). +- [x] T014 Implement restore apply support in `RestoreService` (contract-driven sanitization). +- [x] T015 Add a `WindowsDriverUpdateProfileNormalizer` and register it. + +## Phase 5: Verification +- [x] T016 Run targeted tests. +- [x] T017 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/specs/023-endpoint-security-restore/checklists/requirements.md b/specs/023-endpoint-security-restore/checklists/requirements.md index 2da1d80..7984f8a 100644 --- a/specs/023-endpoint-security-restore/checklists/requirements.md +++ b/specs/023-endpoint-security-restore/checklists/requirements.md @@ -3,12 +3,11 @@ # Requirements Checklist (023) **Created**: 2026-01-03 **Feature**: [spec.md](../spec.md) -- [ ] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`. -- [ ] Restore preview validates template existence and reports missing/ambiguous templates. -- [ ] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message. -- [ ] Settings instances are validated against resolved template definitions before execution. -- [ ] Template mapping strategy is defined for cross-tenant differences (if required) and is tested. -- [ ] Restore create + update paths for Endpoint Security policies are covered by automated tests. -- [ ] Assignments mapping/application for Endpoint Security policies are covered by automated tests. -- [ ] Audit log entries exist for restore execution attempts (success and failure). - +- [x] `endpointSecurityPolicy.restore` is changed to `enabled` in `config/tenantpilot.php`. +- [x] Restore preview validates template existence and reports missing/ambiguous templates. +- [x] Restore execution blocks on missing/ambiguous templates with a clear, actionable error message. +- [x] Settings instances are validated against resolved template definitions before execution. +- [x] Template mapping strategy is defined for cross-tenant differences (if required) and is tested. +- [x] Restore create + update paths for Endpoint Security policies are covered by automated tests. +- [x] Assignments mapping/application for Endpoint Security policies are covered by automated tests. +- [x] Audit log entries exist for restore execution attempts (success and failure). diff --git a/specs/023-endpoint-security-restore/plan.md b/specs/023-endpoint-security-restore/plan.md index c843861..8109384 100644 --- a/specs/023-endpoint-security-restore/plan.md +++ b/specs/023-endpoint-security-restore/plan.md @@ -3,6 +3,7 @@ # Plan: Endpoint Security Policy Restore (023) **Branch**: `feat/023-endpoint-security-restore` **Date**: 2026-01-03 **Input**: [spec.md](./spec.md) +**Status**: Implemented (ready to merge) ## Goal Enable full restore execution for Endpoint Security Policies (`endpointSecurityPolicy`) instead of preview-only, with defensive validation around templates and settings payloads. @@ -29,4 +30,4 @@ ## Approach ## Decisions / Notes - Assume template identifiers may differ across tenants; prefer mapping by `templateFamily` with display-name fallback when required. - Safety-first: if template resolution is ambiguous, treat as missing and block execution. - + - Incident hardening: make restore failures actionable by surfacing Graph path/method and avoid unsafe fallback endpoints. diff --git a/specs/023-endpoint-security-restore/spec.md b/specs/023-endpoint-security-restore/spec.md index 81b3fd5..c8cfe5f 100644 --- a/specs/023-endpoint-security-restore/spec.md +++ b/specs/023-endpoint-security-restore/spec.md @@ -2,13 +2,13 @@ # Feature Specification: Enable Endpoint Security Policy Restore (023) **Feature Branch**: `feat/023-endpoint-security-restore` **Created**: 2026-01-03 -**Status**: Draft +**Status**: Implemented (ready to merge) **Priority**: P1 (Quick Win) ## Context Endpoint Security Policies are already in the `tenantpilot.php` config as `endpointSecurityPolicy` with `restore => 'preview-only'`. Based on Microsoft's recommendation to use the unified `deviceManagement/configurationPolicies` endpoint (over the deprecated `intents` API for new creations), we should enable full restore for this type. -This is a **configuration-only change** with additional validation/testing, not a new policy type implementation. +This is a **restore-mode enablement** with additional validation/testing and targeted restore hardening, not a new policy type implementation. ## User Scenarios & Testing diff --git a/specs/023-endpoint-security-restore/tasks.md b/specs/023-endpoint-security-restore/tasks.md index 049479b..5142e65 100644 --- a/specs/023-endpoint-security-restore/tasks.md +++ b/specs/023-endpoint-security-restore/tasks.md @@ -8,25 +8,30 @@ ## Phase 1: Setup - [x] T001 Create spec/plan/tasks and checklist. ## Phase 2: Inventory & Design -- [ ] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services). -- [ ] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls. -- [ ] T004 Define settings instance validation rules (warning vs block) for restore preview/execution. +- [x] T002 Confirm current restore mode + code paths for `endpointSecurityPolicy` (`config/tenantpilot.php`, restore services). +- [x] T003 Decide template resolution strategy (ID vs family/display name) and required Graph calls. +- [x] T004 Define settings instance validation rules (warning vs block) for restore preview/execution. ## Phase 3: Tests (TDD) -- [ ] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`. -- [ ] T006 Add feature tests for preview warnings when template is missing. -- [ ] T007 Add feature tests asserting restore execution fails gracefully when template is missing. -- [ ] T008 Add tests for settings validation failure paths (invalid/unknown settings instances). -- [ ] T009 Add feature tests asserting assignments are applied for endpoint security policies. +- [x] T005 Add feature tests for restore execution create/update for `endpointSecurityPolicy`. +- [x] T006 Add feature tests for preview warnings when template is missing. +- [x] T007 Add feature tests asserting restore execution fails gracefully when template is missing. +- [x] T008 Add tests for settings validation failure paths (invalid/unknown settings instances). +- [x] T009 Add feature tests asserting assignments are applied for endpoint security policies. ## Phase 4: Implementation -- [ ] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`. -- [ ] T011 Implement template existence validation in restore preview and execution gating. -- [ ] T012 Implement settings instance validation against resolved template definitions. -- [ ] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference. -- [ ] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic. +- [x] T010 Enable restore for `endpointSecurityPolicy` in `config/tenantpilot.php`. +- [x] T011 Implement template existence validation in restore preview and execution gating. +- [x] T012 Implement settings instance validation against resolved template definitions. +- [x] T013 Implement template mapping (if required) and ensure restore payload uses mapped template reference. +- [x] T014 Ensure restore applies assignments for endpoint security policies using existing mapping logic. ## Phase 5: Verification -- [ ] T015 Run targeted tests. -- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`). +- [x] T015 Run targeted tests. +- [x] T016 Run Pint (`./vendor/bin/pint --dirty`). +## Phase 6: Hardening (Incident-driven) +- [x] T017 Default unknown policy types to `preview-only` to avoid invalid Graph endpoints. +- [x] T018 Harden endpoint resolution fallback for configuration policy types (avoid `deviceManagement/{policyType}`). +- [x] T019 Surface Graph method/path in RestoreRun Results for faster debugging. +- [x] T020 Strip non-patchable fields for `endpointSecurityIntent` PATCH (`isAssigned`, `templateId`, `isMigratingToConfigurationPolicy`). diff --git a/tests/Feature/Filament/RestorePreviewTest.php b/tests/Feature/Filament/RestorePreviewTest.php index a4ccb0a..929e83e 100644 --- a/tests/Feature/Filament/RestorePreviewTest.php +++ b/tests/Feature/Filament/RestorePreviewTest.php @@ -104,6 +104,77 @@ public function request(string $method, string $path, array $options = []): Grap expect($policyPreview['action'])->toBe('update'); }); +test('restore preview shows enabled restore mode for windows driver update profiles', function () { + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface + { + 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 getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + }); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-driver-preview', + 'name' => 'Tenant Preview', + 'metadata' => [], + ]); + + $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' => null, + 'policy_identifier' => 'wdp-1', + 'policy_type' => 'windowsDriverUpdateProfile', + 'platform' => 'windows', + 'payload' => [ + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + expect($preview)->toHaveCount(1); + + $policyPreview = $preview[0] ?? []; + expect($policyPreview['policy_type'] ?? null)->toBe('windowsDriverUpdateProfile'); + expect($policyPreview['action'] ?? null)->toBe('create'); + expect($policyPreview['restore_mode'] ?? null)->toBe('enabled'); +}); + test('restore preview warns about missing compliance notification templates', function () { app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface { diff --git a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index fcbf824..4ef31ec 100644 --- a/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -211,3 +211,88 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deployableContentDisplayName'); expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('releaseDateDisplayName'); }); + +test('restore execution applies windows driver update profile with sanitized payload', function () { + $client = new WindowsUpdateProfilesRestoreGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-driver', + 'policy_type' => 'windowsDriverUpdateProfile', + 'display_name' => 'Driver Updates A', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupPayload = [ + 'id' => 'policy-driver', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'inventorySyncStatus' => [ + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + 'roleScopeTagIds' => ['0'], + ]; + + $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' => $backupPayload, + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + ); + + expect($run->status)->toBe('completed'); + expect($run->results[0]['status'])->toBe('applied'); + + expect(PolicyVersion::where('policy_id', $policy->id)->count())->toBe(1); + + expect($client->applyPolicyCalls)->toHaveCount(1); + expect($client->applyPolicyCalls[0]['policyType'])->toBe('windowsDriverUpdateProfile'); + expect($client->applyPolicyCalls[0]['policyId'])->toBe('policy-driver'); + expect($client->applyPolicyCalls[0]['options']['method'] ?? null)->toBe('PATCH'); + + expect($client->applyPolicyCalls[0]['payload']['approvalType'])->toBe('automatic'); + expect($client->applyPolicyCalls[0]['payload']['deploymentDeferralInDays'])->toBe(7); + expect($client->applyPolicyCalls[0]['payload']['roleScopeTagIds'])->toBe(['0']); + + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('id'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('@odata.type'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('deviceReporting'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('newUpdates'); + expect($client->applyPolicyCalls[0]['payload'])->not->toHaveKey('inventorySyncStatus'); +}); diff --git a/tests/Feature/PolicySyncServiceTest.php b/tests/Feature/PolicySyncServiceTest.php index 60beb81..7c056a3 100644 --- a/tests/Feature/PolicySyncServiceTest.php +++ b/tests/Feature/PolicySyncServiceTest.php @@ -62,7 +62,7 @@ $supported = config('tenantpilot.supported_policy_types'); $byType = collect($supported)->keyBy('type'); - expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile']); + expect($byType)->toHaveKeys(['deviceConfiguration', 'windowsUpdateRing', 'windowsFeatureUpdateProfile', 'windowsQualityUpdateProfile', 'windowsDriverUpdateProfile']); expect($byType['deviceConfiguration']['filter'] ?? null) ->toBe("not isof('microsoft.graph.windowsUpdateForBusinessConfiguration')"); @@ -75,6 +75,50 @@ expect($byType['windowsQualityUpdateProfile']['endpoint'] ?? null) ->toBe('deviceManagement/windowsQualityUpdateProfiles'); + + expect($byType['windowsDriverUpdateProfile']['endpoint'] ?? null) + ->toBe('deviceManagement/windowsDriverUpdateProfiles'); +}); + +it('syncs windows driver update profiles from Graph', function () { + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('windowsDriverUpdateProfile', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'wdp-1', + 'displayName' => 'Driver Updates A', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'approvalType' => 'automatic', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'windowsDriverUpdateProfile', 'platform' => 'windows'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'windowsDriverUpdateProfile')->count()) + ->toBe(1); }); it('includes managed device app configurations in supported types', function () { diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9367f93..9256adf 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -41,6 +41,27 @@ public function getPolicy(string $policyType, string $policyId, array $options = ]); } + if ($policyType === 'windowsDriverUpdateProfile') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfile', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'roleScopeTagIds' => ['0'], + 'inventorySyncStatus' => [ + '@odata.type' => '#microsoft.graph.windowsDriverUpdateProfileInventorySyncStatus', + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + ], + ]); + } + return new GraphResponse(success: true, data: [ 'payload' => [ 'id' => $policyId, @@ -271,6 +292,41 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requests[0][3]['select'])->not->toContain('@odata.type'); }); +it('captures windows driver update profile snapshots with full payload', function () { + $client = new PolicySnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-driver', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'wdp-123', + 'policy_type' => 'windowsDriverUpdateProfile', + 'display_name' => 'Driver Updates A', + 'platform' => 'windows', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload']['approvalType'] ?? null)->toBe('automatic'); + expect($result['payload']['deploymentDeferralInDays'] ?? null)->toBe(7); + expect($result['payload']['deviceReporting'] ?? null)->toBe(12); + expect($result['payload']['newUpdates'] ?? null)->toBe(3); + expect($result['payload']['inventorySyncStatus']['driverInventorySyncState'] ?? null)->toBe('success'); + + expect($client->requests[0][0])->toBe('getPolicy'); + expect($client->requests[0][1])->toBe('windowsDriverUpdateProfile'); + expect($client->requests[0][2])->toBe('wdp-123'); +}); + test('falls back to metadata-only snapshot when mamAppConfiguration returns 500', function () { $client = Mockery::mock(\App\Services\Graph\GraphClientInterface::class); $client->shouldReceive('getPolicy') diff --git a/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php new file mode 100644 index 0000000..226e594 --- /dev/null +++ b/tests/Unit/WindowsDriverUpdateProfileNormalizerTest.php @@ -0,0 +1,38 @@ + '#microsoft.graph.windowsDriverUpdateProfile', + 'displayName' => 'Driver Updates A', + 'description' => 'Drivers rollout policy', + 'approvalType' => 'automatic', + 'deploymentDeferralInDays' => 7, + 'deviceReporting' => 12, + 'newUpdates' => 3, + 'inventorySyncStatus' => [ + 'driverInventorySyncState' => 'success', + 'lastSuccessfulSyncDateTime' => '2026-01-01T00:00:00Z', + ], + ]; + + $result = $normalizer->normalize($snapshot, 'windowsDriverUpdateProfile', 'windows'); + + expect($result['status'])->toBe('success'); + expect($result['settings'])->toBeArray()->not->toBeEmpty(); + + $driverBlock = collect($result['settings']) + ->first(fn (array $block) => ($block['title'] ?? null) === 'Driver Update Profile'); + + expect($driverBlock)->not->toBeNull(); + + $keys = collect($driverBlock['entries'] ?? [])->pluck('key')->all(); + + expect($keys)->toContain('Approval type', 'Deployment deferral (days)', 'Devices reporting', 'New driver updates'); +}); From 602195324b6de8f6b62e10bbfc94138e4be28da4 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 02:27:44 +0000 Subject: [PATCH 11/18] spec/024-additional-intune-types (#28) specs for additional intune types Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/28 --- .../checklists/requirements.md | 15 ++++++ specs/024-terms-and-conditions/plan.md | 23 ++++++++ specs/024-terms-and-conditions/spec.md | 51 ++++++++++++++++++ specs/024-terms-and-conditions/tasks.md | 33 ++++++++++++ .../checklists/requirements.md | 13 +++++ specs/025-policy-sets/plan.md | 27 ++++++++++ specs/025-policy-sets/spec.md | 51 ++++++++++++++++++ specs/025-policy-sets/tasks.md | 31 +++++++++++ .../checklists/requirements.md | 14 +++++ specs/026-custom-compliance-scripts/plan.md | 25 +++++++++ specs/026-custom-compliance-scripts/spec.md | 52 +++++++++++++++++++ specs/026-custom-compliance-scripts/tasks.md | 33 ++++++++++++ .../checklists/requirements.md | 13 +++++ specs/027-enrollment-config-subtypes/plan.md | 21 ++++++++ specs/027-enrollment-config-subtypes/spec.md | 46 ++++++++++++++++ specs/027-enrollment-config-subtypes/tasks.md | 28 ++++++++++ .../checklists/requirements.md | 11 ++++ specs/028-device-categories/plan.md | 21 ++++++++ specs/028-device-categories/spec.md | 30 +++++++++++ specs/028-device-categories/tasks.md | 27 ++++++++++ .../checklists/requirements.md | 14 +++++ specs/029-wip-policies/plan.md | 23 ++++++++ specs/029-wip-policies/spec.md | 41 +++++++++++++++ specs/029-wip-policies/tasks.md | 32 ++++++++++++ .../checklists/requirements.md | 12 +++++ specs/030-intune-rbac-backup/plan.md | 24 +++++++++ specs/030-intune-rbac-backup/spec.md | 51 ++++++++++++++++++ specs/030-intune-rbac-backup/tasks.md | 29 +++++++++++ 28 files changed, 791 insertions(+) create mode 100644 specs/024-terms-and-conditions/checklists/requirements.md create mode 100644 specs/024-terms-and-conditions/plan.md create mode 100644 specs/024-terms-and-conditions/spec.md create mode 100644 specs/024-terms-and-conditions/tasks.md create mode 100644 specs/025-policy-sets/checklists/requirements.md create mode 100644 specs/025-policy-sets/plan.md create mode 100644 specs/025-policy-sets/spec.md create mode 100644 specs/025-policy-sets/tasks.md create mode 100644 specs/026-custom-compliance-scripts/checklists/requirements.md create mode 100644 specs/026-custom-compliance-scripts/plan.md create mode 100644 specs/026-custom-compliance-scripts/spec.md create mode 100644 specs/026-custom-compliance-scripts/tasks.md create mode 100644 specs/027-enrollment-config-subtypes/checklists/requirements.md create mode 100644 specs/027-enrollment-config-subtypes/plan.md create mode 100644 specs/027-enrollment-config-subtypes/spec.md create mode 100644 specs/027-enrollment-config-subtypes/tasks.md create mode 100644 specs/028-device-categories/checklists/requirements.md create mode 100644 specs/028-device-categories/plan.md create mode 100644 specs/028-device-categories/spec.md create mode 100644 specs/028-device-categories/tasks.md create mode 100644 specs/029-wip-policies/checklists/requirements.md create mode 100644 specs/029-wip-policies/plan.md create mode 100644 specs/029-wip-policies/spec.md create mode 100644 specs/029-wip-policies/tasks.md create mode 100644 specs/030-intune-rbac-backup/checklists/requirements.md create mode 100644 specs/030-intune-rbac-backup/plan.md create mode 100644 specs/030-intune-rbac-backup/spec.md create mode 100644 specs/030-intune-rbac-backup/tasks.md diff --git a/specs/024-terms-and-conditions/checklists/requirements.md b/specs/024-terms-and-conditions/checklists/requirements.md new file mode 100644 index 0000000..64fb84d --- /dev/null +++ b/specs/024-terms-and-conditions/checklists/requirements.md @@ -0,0 +1,15 @@ +# Requirements Checklist (024) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `termsAndConditions` exists in `config/tenantpilot.php` with correct category/risk/restore mode. +- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, assignments CRUD paths). +- [ ] Sync lists and stores T&C in inventory. +- [ ] Snapshot capture stores full payload + assignments. +- [ ] Restore preview shows correct mode and warnings. +- [ ] Restore execution applies only patchable properties and writes audit logs. +- [ ] Normalized settings view is readable for admins. +- [ ] Pest tests cover sync + snapshot + restore preview + execution. +- [ ] Pint run (`./vendor/bin/pint --dirty`) on touched files. + diff --git a/specs/024-terms-and-conditions/plan.md b/specs/024-terms-and-conditions/plan.md new file mode 100644 index 0000000..df1a6fc --- /dev/null +++ b/specs/024-terms-and-conditions/plan.md @@ -0,0 +1,23 @@ +# Plan: Terms & Conditions (Enrollment Experience) (024) + +**Branch**: `feat/024-terms-and-conditions` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph contract details for Terms & Conditions: + - resource path: `deviceManagement/termsAndConditions` + - `@odata.type` values and patchable fields + - assignments endpoints: `/deviceManagement/termsAndConditions/{id}/assignments` (CRUD) +2. Add `termsAndConditions` to `config/tenantpilot.php` (category “Enrollment Experience”, risk, restore mode). +3. Add contract entry to `config/graph_contracts.php`: + - resource, type family, create/update methods + - assignments list/create/update/delete paths (no `/assign` action here) +4. Ensure policy sync, snapshot capture, and restore use the config/contract-driven paths (minimal special casing). +5. Add a normalizer for readable UI output and ensure diff output is stable. +6. Add targeted Pest coverage (sync + snapshot + preview + execution). + +## Decisions / Notes +- **Restore mode**: default `enabled` (risk: medium-high) with strict preview/confirmation and audit logging. +- **Assignments**: use assignment CRUD paths (POST to `/assignments`) rather than `/assign`. + diff --git a/specs/024-terms-and-conditions/spec.md b/specs/024-terms-and-conditions/spec.md new file mode 100644 index 0000000..a1de713 --- /dev/null +++ b/specs/024-terms-and-conditions/spec.md @@ -0,0 +1,51 @@ +# Feature Specification: Terms & Conditions (Enrollment Experience) (024) + +**Feature Branch**: `feat/024-terms-and-conditions` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +Terms & Conditions (T&C) are part of the **Enrollment Experience**. During tenant rebuilds / recovery they are frequently missed, but can be required for compliant onboarding. + +## User Scenarios & Testing + +### User Story 1 — Inventory + readable view (Priority: P1) +As an admin, I can see Terms & Conditions policies in the Policies inventory and view their configuration in a readable way. + +**Acceptance Scenarios** +1. Given a tenant with T&C configured, when I sync policies, then T&C items appear with type `termsAndConditions`. +2. Given a T&C policy, when I open its detail page, then I see a normalized settings view (not only raw JSON). + +### User Story 2 — Snapshot capture + versioning (Priority: P1) +As an admin, I can capture versions and backups of Terms & Conditions so I can diff and roll back safely. + +**Acceptance Scenarios** +1. Given a T&C policy, when I capture a snapshot, then the full Graph payload is stored immutably (JSONB). +2. Given two versions, when I view a diff, then changes are human-readable and structured. + +### User Story 3 — Restore preview + execution (Priority: P2) +As an admin, I can restore Terms & Conditions (with assignments) from a snapshot with a safe preview, audit logging, and defensive checks. + +**Acceptance Scenarios** +1. Given a backup item of type `termsAndConditions`, when I run restore preview, then it shows create/update + restore mode and warnings. +2. Given restore execution, when Graph rejects non-patchable fields, then TenantPilot strips them (contract-driven) and retries safely. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy type `termsAndConditions` backed by Graph `deviceManagement/termsAndConditions`. +- **FR-002**: Capture full payload snapshots and include assignments. +- **FR-003**: Restore supports create/update (contract-driven sanitization) and assignment apply. +- **FR-004**: Normalized settings view exists for key fields (displayName, description, title, body, acceptance statement, etc.). +- **FR-005**: Add Pest tests for sync + snapshot + restore preview + restore execution. + +### Non-Functional Requirements +- **NFR-001**: All writes require explicit confirmation and create audit logs. +- **NFR-002**: Tenant isolation applies end-to-end (no cross-tenant leakage). + +## Success Criteria +- **SC-001**: T&C appears in inventory and backups. +- **SC-002**: Restore preview is actionable and safe. +- **SC-003**: Restore execution works with assignments (where Graph allows). + diff --git a/specs/024-terms-and-conditions/tasks.md b/specs/024-terms-and-conditions/tasks.md new file mode 100644 index 0000000..8e6b0c2 --- /dev/null +++ b/specs/024-terms-and-conditions/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Terms & Conditions (Enrollment Experience) (024) + +**Branch**: `feat/024-terms-and-conditions` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource, `@odata.type`, patchable vs read-only fields. +- [ ] T003 Confirm assignments endpoints and payload shapes for create/update/delete. +- [ ] T004 Decide restore mode (`enabled` vs `preview-only`) and risk classification. + +## Phase 3: Tests (TDD) +- [ ] T005 Add sync test for `termsAndConditions`. +- [ ] T006 Add snapshot capture test (payload + assignments). +- [ ] T007 Add restore preview test (restore_mode + action). +- [ ] T008 Add restore execution test (sanitization + assignment apply). +- [ ] T009 Add normalized display test for key fields. + +## Phase 4: Implementation +- [ ] T010 Add `termsAndConditions` to `config/tenantpilot.php`. +- [ ] T011 Add Graph contract entry in `config/graph_contracts.php` (resource + assignment CRUD paths). +- [ ] T012 Ensure `PolicySyncService` imports these policies correctly. +- [ ] T013 Ensure `PolicySnapshotService` captures full payload and assignments. +- [ ] T014 Ensure `RestoreService` applies create/update and assignments (contract-driven). +- [ ] T015 Add `TermsAndConditionsNormalizer` and register it. + +## Phase 5: Verification +- [ ] T016 Run targeted tests. +- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/025-policy-sets/checklists/requirements.md b/specs/025-policy-sets/checklists/requirements.md new file mode 100644 index 0000000..727bc41 --- /dev/null +++ b/specs/025-policy-sets/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (025) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `policySet` exists in `config/tenantpilot.php` (category, endpoint, restore mode, risk). +- [ ] Graph contract exists in `config/graph_contracts.php` (resource, items hydration, assignments paths). +- [ ] Sync lists and stores Policy Sets in inventory. +- [ ] Snapshot capture includes Policy Set items and assignments. +- [ ] Restore preview produces a linking report and blocks unsafe execution. +- [ ] Normalized settings view is readable (items + assignments). +- [ ] Pest tests cover sync + snapshot + preview. + diff --git a/specs/025-policy-sets/plan.md b/specs/025-policy-sets/plan.md new file mode 100644 index 0000000..f7ec684 --- /dev/null +++ b/specs/025-policy-sets/plan.md @@ -0,0 +1,27 @@ +# Plan: Policy Sets (Intune native bundling) (025) + +**Branch**: `feat/025-policy-sets` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph API surface: + - resource: `deviceAppManagement/policySets` + - item model + subresource path (`/policySets/{id}/items`) + - assignments subresource (`/policySets/{id}/assignments`) +2. Add `policySet` to `config/tenantpilot.php` (category “Apps/MAM”, risk, restore mode). +3. Add contract entry in `config/graph_contracts.php`: + - resource + type family + - member hydration strategy for items (subresource) + - assignments CRUD paths (if supported) +4. Extend snapshot capture to hydrate `items` (and assignments). +5. Implement restore preview “linking report”: + - identify referenced object IDs inside items + - attempt mapping by (type, displayName, externalId) where possible + - surface missing dependencies and block execution by default +6. Add targeted Pest tests for sync + snapshot hydration + preview report. + +## Decisions / Notes +- **Restore mode**: default `preview-only` until a robust cross-tenant linking/mapping strategy exists. +- Policy Sets are not “settings restore”; they are primarily a **relationship/linking** restore step. + diff --git a/specs/025-policy-sets/spec.md b/specs/025-policy-sets/spec.md new file mode 100644 index 0000000..39050f6 --- /dev/null +++ b/specs/025-policy-sets/spec.md @@ -0,0 +1,51 @@ +# Feature Specification: Policy Sets (Intune native bundling) (025) + +**Feature Branch**: `feat/025-policy-sets` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +Policy Sets are an Intune-native way to bundle multiple policies/apps into a deployable set. For tenants that rely on Policy Sets, “Tenant-as-Code” is incomplete without at least inventory + backup and a restore preview that highlights missing links. + +## User Scenarios & Testing + +### User Story 1 — Inventory + view Policy Sets (Priority: P1) +As an admin, I can see Policy Sets and inspect their composition (items) and assignments. + +**Acceptance Scenarios** +1. Given a tenant uses Policy Sets, when I sync policies, then Policy Sets appear as type `policySet`. +2. Given a Policy Set, when I view details, then I see a readable list of included items and assignments. + +### User Story 2 — Backup + version history (Priority: P1) +As an admin, I can capture immutable snapshots of Policy Sets (including items) and diff versions. + +**Acceptance Scenarios** +1. Given a Policy Set, when I add it to a backup set, then the snapshot includes items and assignments (as supported by Graph). +2. Given two versions, diffs highlight changed items and assignment targets. + +### User Story 3 — Restore preview (linking) (Priority: P1) +As an admin, I can run a restore preview that explains which Policy Set items can be linked in the target tenant and which are missing. + +**Acceptance Scenarios** +1. Given a Policy Set snapshot referencing policies/apps by ID, when I run preview, then TenantPilot reports missing vs resolvable items. +2. Given missing referenced objects, preview warns and blocks execution unless resolved. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy type `policySet` backed by Graph `deviceAppManagement/policySets`. +- **FR-002**: Capture Policy Set payload + `items` subresource (and assignments if applicable). +- **FR-003**: Restore preview MUST validate referenced IDs and provide a linking report. +- **FR-004**: Restore execution is allowed only when all referenced items can be mapped safely (or stays preview-only initially). +- **FR-005**: Add Pest tests for sync + snapshot + preview linking report. + +### Non-Functional Requirements +- **NFR-001**: No destructive writes without explicit confirmation and audit logs. +- **NFR-002**: Linking errors must be actionable (show which item is missing and why). + +## Success Criteria +- **SC-001**: Policy Sets are visible and backed up. +- **SC-002**: Preview makes missing dependencies obvious. +- **SC-003**: If enabled, execution links only safe, mapped items. + diff --git a/specs/025-policy-sets/tasks.md b/specs/025-policy-sets/tasks.md new file mode 100644 index 0000000..3331266 --- /dev/null +++ b/specs/025-policy-sets/tasks.md @@ -0,0 +1,31 @@ +# Tasks: Policy Sets (Intune native bundling) (025) + +**Branch**: `feat/025-policy-sets` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource + `@odata.type` for Policy Sets. +- [ ] T003 Confirm item subresource shape (`/items`) and how referenced objects are represented. +- [ ] T004 Confirm assignment endpoints (`/assignments`) and payload shape. +- [ ] T005 Define restore preview “linking report” rules and execution gating. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test importing Policy Sets. +- [ ] T007 Add snapshot test capturing items (and assignments). +- [ ] T008 Add restore preview test showing linking report (missing vs resolvable). + +## Phase 4: Implementation +- [ ] T009 Add `policySet` to `config/tenantpilot.php`. +- [ ] T010 Add contract entry in `config/graph_contracts.php` (resource + item hydration + assignments). +- [ ] T011 Implement snapshot hydration for `items` and assignment capture. +- [ ] T012 Implement restore preview linking report and safe gating. +- [ ] T013 Add a normalizer for readable UI output (items summary + assignment summary). + +## Phase 5: Verification +- [ ] T014 Run targeted tests. +- [ ] T015 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/026-custom-compliance-scripts/checklists/requirements.md b/specs/026-custom-compliance-scripts/checklists/requirements.md new file mode 100644 index 0000000..6689320 --- /dev/null +++ b/specs/026-custom-compliance-scripts/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (026) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `deviceComplianceScript` exists in `config/tenantpilot.php` (category, endpoint, restore mode, risk). +- [ ] Graph contract exists in `config/graph_contracts.php` (resource, type family, assignments paths). +- [ ] Sync lists and stores compliance scripts in inventory. +- [ ] Snapshot capture stores full payload + assignments. +- [ ] Restore preview is available and respects restore mode. +- [ ] Restore execution applies only patchable fields and re-encodes script content correctly. +- [ ] Normalized settings view is readable and safe. +- [ ] Pest tests cover sync + snapshot + preview + execution. + diff --git a/specs/026-custom-compliance-scripts/plan.md b/specs/026-custom-compliance-scripts/plan.md new file mode 100644 index 0000000..5e8dde0 --- /dev/null +++ b/specs/026-custom-compliance-scripts/plan.md @@ -0,0 +1,25 @@ +# Plan: Custom Compliance Scripts (Windows) (026) + +**Branch**: `feat/026-custom-compliance-scripts` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph contract details: + - resource: `deviceManagement/deviceComplianceScripts` (beta) + - patchable fields vs read-only fields + - assignment pattern: `/deviceComplianceScripts/{id}/assign` and `/assignments` +2. Add `deviceComplianceScript` to `config/tenantpilot.php` (category “Compliance”, risk, restore mode). +3. Add contract entry to `config/graph_contracts.php` (resource + assignment endpoints + scope tags support). +4. Implement snapshot capture: + - ensure `detectionScriptContent` is preserved and treated like other scripts (safe display, encode/decode where needed) +5. Implement restore: + - sanitize payload via contract + - ensure `detectionScriptContent` is encoded as expected by Graph + - apply assignments via assign action +6. Add normalizer and targeted tests. + +## Decisions / Notes +- **Restore mode**: default `enabled` (risk: medium-high) because tenant recovery often depends on these scripts. +- Use the existing script content display rules (`TENANTPILOT_SHOW_SCRIPT_CONTENT`, max chars). + diff --git a/specs/026-custom-compliance-scripts/spec.md b/specs/026-custom-compliance-scripts/spec.md new file mode 100644 index 0000000..fd7f940 --- /dev/null +++ b/specs/026-custom-compliance-scripts/spec.md @@ -0,0 +1,52 @@ +# Feature Specification: Custom Compliance Scripts (Windows) (026) + +**Feature Branch**: `feat/026-custom-compliance-scripts` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +Windows Custom Compliance is widely used. Without `deviceComplianceScripts`, backup/restore for compliance posture is incomplete. Restore must include assignments. + +## User Scenarios & Testing + +### User Story 1 — Inventory + view compliance scripts (Priority: P1) +As an admin, I can see Custom Compliance Scripts in inventory and view their script/config in a readable way. + +**Acceptance Scenarios** +1. Given device compliance scripts exist, sync shows them as type `deviceComplianceScript`. +2. Detail view shows key settings (runAsAccount, enforceSignatureCheck, runAs32Bit) and script content (safe display rules). + +### User Story 2 — Backup + versioning (Priority: P1) +As an admin, I can capture versions/backups of compliance scripts so I can diff changes. + +**Acceptance Scenarios** +1. Snapshot capture stores the full payload including `detectionScriptContent`. +2. Diff highlights script changes and operational flags. + +### User Story 3 — Restore preview + execution (Priority: P1) +As an admin, I can restore a compliance script and its assignments defensively. + +**Acceptance Scenarios** +1. Preview shows create/update + restore mode and warnings. +2. Execution strips read-only fields and re-encodes script content correctly. +3. Assignments are applied via Graph assign action. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy type `deviceComplianceScript` backed by Graph `deviceManagement/deviceComplianceScripts` (beta). +- **FR-002**: Snapshot stores full payload (including `detectionScriptContent`) and assignments. +- **FR-003**: Restore supports create/update with contract-driven sanitization. +- **FR-004**: Restore applies assignments (`/assign`) and records audit logs. +- **FR-005**: Add normalized display support for key fields and script content (with safety limits). +- **FR-006**: Add Pest tests for sync + snapshot + preview + execution. + +### Non-Functional Requirements +- **NFR-001**: Script content must never be logged; UI display must be bounded (config-driven). +- **NFR-002**: Preview-only fallback when Graph returns unexpected shapes or missing contracts. + +## Success Criteria +- **SC-001**: Custom compliance scripts appear in inventory and backups. +- **SC-002**: Restore execution works and assignments are applied. + diff --git a/specs/026-custom-compliance-scripts/tasks.md b/specs/026-custom-compliance-scripts/tasks.md new file mode 100644 index 0000000..0b4e46b --- /dev/null +++ b/specs/026-custom-compliance-scripts/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Custom Compliance Scripts (Windows) (026) + +**Branch**: `feat/026-custom-compliance-scripts` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource + `@odata.type` and required permissions. +- [ ] T003 Confirm patchable fields and define `update_strip_keys` / `update_whitelist`. +- [ ] T004 Confirm assignments endpoints (`/assignments`, `/assign`) and body shape. +- [ ] T005 Decide restore mode + risk classification. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test for `deviceComplianceScript`. +- [ ] T007 Add snapshot/version capture test (incl. `detectionScriptContent`). +- [ ] T008 Add restore preview test (restore_mode + action). +- [ ] T009 Add restore execution test (sanitization + assignment apply). +- [ ] T010 Add normalized display test for key fields. + +## Phase 4: Implementation +- [ ] T011 Add `deviceComplianceScript` to `config/tenantpilot.php`. +- [ ] T012 Add Graph contract entry in `config/graph_contracts.php`. +- [ ] T013 Implement snapshot capture handling (script content preservation rules). +- [ ] T014 Implement restore apply support (contract-driven sanitization + assignments). +- [ ] T015 Add `DeviceComplianceScriptNormalizer` and register it. + +## Phase 5: Verification +- [ ] T016 Run targeted tests. +- [ ] T017 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/027-enrollment-config-subtypes/checklists/requirements.md b/specs/027-enrollment-config-subtypes/checklists/requirements.md new file mode 100644 index 0000000..6b9891d --- /dev/null +++ b/specs/027-enrollment-config-subtypes/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (027) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] New enrollment config subtypes exist in `config/tenantpilot.php`. +- [ ] Graph contracts exist with correct type families. +- [ ] Sync classifies each subtype correctly (no collapsing into `enrollmentRestriction`). +- [ ] Snapshot capture stores full payloads. +- [ ] Restore preview works and defaults to preview-only. +- [ ] Normalized view is readable for admins. +- [ ] Pest tests cover sync + snapshot + preview. + diff --git a/specs/027-enrollment-config-subtypes/plan.md b/specs/027-enrollment-config-subtypes/plan.md new file mode 100644 index 0000000..17a513a --- /dev/null +++ b/specs/027-enrollment-config-subtypes/plan.md @@ -0,0 +1,21 @@ +# Plan: Enrollment Configuration Subtypes (027) + +**Branch**: `feat/027-enrollment-config-subtypes` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph details and type-family values for each subtype (`@odata.type`). +2. Add new types to `config/tenantpilot.php` (category “Enrollment Experience”, risk, restore mode). +3. Add contracts to `config/graph_contracts.php`: + - resource `deviceManagement/deviceEnrollmentConfigurations` + - type families per subtype + - assignments endpoints (if supported) or mark as unsupported +4. Update `PolicySyncService` enrollment classification logic to route each item to the correct subtype. +5. Ensure snapshot capture can fetch these items without special casing. +6. Implement restore preview entries; keep execution preview-only until validated. +7. Add targeted Pest tests. + +## Decisions / Notes +- All enrollment configuration subtypes should default to `preview-only` restore initially due to enrollment impact risk. + diff --git a/specs/027-enrollment-config-subtypes/spec.md b/specs/027-enrollment-config-subtypes/spec.md new file mode 100644 index 0000000..1cccd8b --- /dev/null +++ b/specs/027-enrollment-config-subtypes/spec.md @@ -0,0 +1,46 @@ +# Feature Specification: Enrollment Configuration Subtypes (027) + +**Feature Branch**: `feat/027-enrollment-config-subtypes` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P1 + +## Context +TenantPilot already covers ESP and Enrollment Restrictions, but there are additional subtypes in the same `deviceEnrollmentConfigurations` collection that are often forgotten: +- Enrollment Limit (`deviceEnrollmentLimitConfiguration`) +- Platform Restrictions (`deviceEnrollmentPlatformRestrictionsConfiguration`) +- Enrollment Notifications (`deviceEnrollmentNotificationConfiguration`, beta) + +## User Scenarios & Testing + +### User Story 1 — Inventory shows each subtype separately (Priority: P1) +As an admin, I can sync enrollment configurations and see each subtype as its own policy type. + +**Acceptance Scenarios** +1. Given enrollment limit configurations exist, sync shows type `deviceEnrollmentLimitConfiguration`. +2. Given platform restriction configurations exist, sync shows type `deviceEnrollmentPlatformRestrictionsConfiguration`. +3. Given enrollment notifications exist, sync shows type `deviceEnrollmentNotificationConfiguration`. + +### User Story 2 — Backup + restore preview (Priority: P1) +As an admin, I can back up and preview-restore these enrollment configurations safely. + +**Acceptance Scenarios** +1. Backup captures full payloads for each subtype. +2. Restore preview lists create/update actions and shows preview-only warnings for enrollment-risky configs. + +## Requirements + +### Functional Requirements +- **FR-001**: Add three new policy types backed by `deviceManagement/deviceEnrollmentConfigurations`: + - `deviceEnrollmentLimitConfiguration` + - `deviceEnrollmentPlatformRestrictionsConfiguration` + - `deviceEnrollmentNotificationConfiguration` +- **FR-002**: Update classification so these do not collapse into `enrollmentRestriction`. +- **FR-003**: Snapshot capture stores full payload and assignments (where supported). +- **FR-004**: Restore preview is supported; execution is conservative (likely preview-only initially). +- **FR-005**: Add Pest tests for sync + snapshot + preview. + +## Success Criteria +- **SC-001**: Enrollment configuration subtypes are visible and correctly classified. +- **SC-002**: Backups include these objects, and preview explains safe restore behavior. + diff --git a/specs/027-enrollment-config-subtypes/tasks.md b/specs/027-enrollment-config-subtypes/tasks.md new file mode 100644 index 0000000..46d669c --- /dev/null +++ b/specs/027-enrollment-config-subtypes/tasks.md @@ -0,0 +1,28 @@ +# Tasks: Enrollment Configuration Subtypes (027) + +**Branch**: `feat/027-enrollment-config-subtypes` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. +- [ ] T003 Decide restore modes and risk levels. + +## Phase 3: Tests (TDD) +- [ ] T004 Add sync tests ensuring each subtype is classified correctly. +- [ ] T005 Add snapshot capture test for at least one subtype. +- [ ] T006 Add restore preview test ensuring preview-only behavior. + +## Phase 4: Implementation +- [ ] T007 Add new types to `config/tenantpilot.php`. +- [ ] T008 Add contracts in `config/graph_contracts.php` (resource + type families). +- [ ] T009 Update `PolicySyncService` enrollment classification logic. +- [ ] T010 Add normalizer for readable UI output (key fields per subtype). + +## Phase 5: Verification +- [ ] T011 Run targeted tests. +- [ ] T012 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/028-device-categories/checklists/requirements.md b/specs/028-device-categories/checklists/requirements.md new file mode 100644 index 0000000..e74fd5f --- /dev/null +++ b/specs/028-device-categories/checklists/requirements.md @@ -0,0 +1,11 @@ +# Requirements Checklist (028) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `deviceCategory` exists in `config/tenantpilot.php` under `foundation_types`. +- [ ] Graph contract exists in `config/graph_contracts.php` for device categories. +- [ ] Backup sets include device categories as foundation items. +- [ ] Restore recreates missing categories idempotently and writes audit logs. +- [ ] Pest tests cover foundation snapshot + restore. + diff --git a/specs/028-device-categories/plan.md b/specs/028-device-categories/plan.md new file mode 100644 index 0000000..4d69a4a --- /dev/null +++ b/specs/028-device-categories/plan.md @@ -0,0 +1,21 @@ +# Plan: Device Categories (Enrollment/Organization) (028) + +**Branch**: `feat/028-device-categories` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph endpoints and patchable fields: + - list: `GET /deviceManagement/deviceCategories` + - create/update/delete supported +2. Add `deviceCategory` to `config/tenantpilot.php` under `foundation_types` (risk low, restore enabled). +3. Add contract entry in `config/graph_contracts.php` for foundations (resource + create/update methods). +4. Extend `FoundationSnapshotService` to fetch categories (list + per-item payload). +5. Extend `FoundationMappingService` and restore flow: + - match by `displayName` + - create missing +6. Add targeted Pest tests for foundation capture + restore. + +## Decisions / Notes +- This is modeled as a **foundation type** (captured automatically with backup sets), not a Policy inventory type. + diff --git a/specs/028-device-categories/spec.md b/specs/028-device-categories/spec.md new file mode 100644 index 0000000..e8cc656 --- /dev/null +++ b/specs/028-device-categories/spec.md @@ -0,0 +1,30 @@ +# Feature Specification: Device Categories (Enrollment/Organization) (028) + +**Feature Branch**: `feat/028-device-categories` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P2 + +## Context +Device Categories are not a “policy”, but they are frequently needed for tenant rebuilds and enrollment flows. + +## User Scenarios & Testing + +### User Story 1 — Backup + restore Device Categories (Priority: P1) +As an admin, when I create a backup set, Device Categories are captured as a foundation object and can be restored safely. + +**Acceptance Scenarios** +1. Given device categories exist, when I create a backup, then categories are included as foundation items. +2. Given a target tenant is missing categories, when I restore, then categories are recreated (idempotent by display name). + +## Requirements + +### Functional Requirements +- **FR-001**: Add foundation type `deviceCategory` backed by `deviceManagement/deviceCategories`. +- **FR-002**: Backup captures all categories with minimal metadata. +- **FR-003**: Restore recreates categories idempotently (match by displayName) and records audit logs. +- **FR-004**: Add targeted tests for foundation snapshot + restore. + +## Success Criteria +- **SC-001**: Device Categories are present in backups and can be recreated. + diff --git a/specs/028-device-categories/tasks.md b/specs/028-device-categories/tasks.md new file mode 100644 index 0000000..81e756f --- /dev/null +++ b/specs/028-device-categories/tasks.md @@ -0,0 +1,27 @@ +# Tasks: Device Categories (Enrollment/Organization) (028) + +**Branch**: `feat/028-device-categories` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph resource + patchability for `deviceCategories`. +- [ ] T003 Decide mapping rules (by displayName) and restore idempotency behavior. + +## Phase 3: Tests (TDD) +- [ ] T004 Add foundation snapshot test for `deviceCategory`. +- [ ] T005 Add foundation restore test (create missing + idempotent behavior). + +## Phase 4: Implementation +- [ ] T006 Add `deviceCategory` to `config/tenantpilot.php` foundation types. +- [ ] T007 Add contract entry in `config/graph_contracts.php`. +- [ ] T008 Implement foundation snapshot fetch for device categories. +- [ ] T009 Implement foundation restore mapping + apply. + +## Phase 5: Verification +- [ ] T010 Run targeted tests. +- [ ] T011 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/029-wip-policies/checklists/requirements.md b/specs/029-wip-policies/checklists/requirements.md new file mode 100644 index 0000000..fe819c0 --- /dev/null +++ b/specs/029-wip-policies/checklists/requirements.md @@ -0,0 +1,14 @@ +# Requirements Checklist (029) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] `windowsInformationProtectionPolicy` and `mdmWindowsInformationProtectionPolicy` exist in `config/tenantpilot.php`. +- [ ] Graph contracts exist with correct resources/type families/assignment endpoints. +- [ ] Sync lists and stores both WIP types separately. +- [ ] Snapshot capture stores full payload + assignments. +- [ ] Restore preview explains gating and risks. +- [ ] If enabled, restore execution uses derived endpoints and sanitizes payloads. +- [ ] Normalized view is readable for admins. +- [ ] Pest tests cover sync + snapshot + preview (and execution if enabled). + diff --git a/specs/029-wip-policies/plan.md b/specs/029-wip-policies/plan.md new file mode 100644 index 0000000..5f81ef2 --- /dev/null +++ b/specs/029-wip-policies/plan.md @@ -0,0 +1,23 @@ +# Plan: Windows Information Protection (WIP) Policies (029) + +**Branch**: `feat/029-wip-policies` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph behavior: + - endpoints for both WIP collections + - assignment endpoints (list + assign/create shape) + - patchable/read-only fields and required permissions +2. Add new types to `config/tenantpilot.php` (category “Apps/MAM”, platform windows, restore mode/risk). +3. Add graph contracts in `config/graph_contracts.php`: + - resource paths + - type families + - assignment endpoints +4. Ensure restore uses the derived entity set endpoint (do not PATCH generic `managedAppPolicies/{id}` when Graph requires derived resources). +5. Add a normalizer for readable UI output. +6. Add targeted Pest coverage. + +## Decisions / Notes +- **Restore mode**: default `preview-only` until endpoint + assignment behavior is confirmed with tests and real tenants. + diff --git a/specs/029-wip-policies/spec.md b/specs/029-wip-policies/spec.md new file mode 100644 index 0000000..87d884e --- /dev/null +++ b/specs/029-wip-policies/spec.md @@ -0,0 +1,41 @@ +# Feature Specification: Windows Information Protection (WIP) Policies (029) + +**Feature Branch**: `feat/029-wip-policies` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P2 + +## Context +Some tenants rely on WIP (MAM/WIP). These policies live under `deviceAppManagement` and should be treated as first-class objects for backup/restore. + +## User Scenarios & Testing + +### User Story 1 — Inventory shows WIP policies separately (Priority: P1) +As an admin, I can see WIP policies as their own types (not mixed into generic MAM policies). + +**Acceptance Scenarios** +1. Sync lists WIP policies from Graph and stores them as `windowsInformationProtectionPolicy`. +2. Sync lists MDM WIP policies and stores them as `mdmWindowsInformationProtectionPolicy`. + +### User Story 2 — Backup + restore (Priority: P2) +As an admin, I can back up and restore WIP policies with assignments safely. + +**Acceptance Scenarios** +1. Snapshot capture stores the full policy payload and assignments. +2. Restore execution uses the correct derived entity set endpoint for create/update. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy types: + - `windowsInformationProtectionPolicy` → `deviceAppManagement/windowsInformationProtectionPolicies` + - `mdmWindowsInformationProtectionPolicy` → `deviceAppManagement/mdmWindowsInformationProtectionPolicies` +- **FR-002**: Capture full payload + assignments. +- **FR-003**: Restore supports create/update with contract-driven sanitization and assignment apply. +- **FR-004**: Add normalized display for key WIP fields (protected apps/identities, enforcement level, exemptions, etc.). +- **FR-005**: Add Pest tests for sync + snapshot + restore preview/execution. + +## Success Criteria +- **SC-001**: WIP policies appear and can be backed up. +- **SC-002**: Restore preview/execution uses correct endpoints and is auditable. + diff --git a/specs/029-wip-policies/tasks.md b/specs/029-wip-policies/tasks.md new file mode 100644 index 0000000..1f1ded8 --- /dev/null +++ b/specs/029-wip-policies/tasks.md @@ -0,0 +1,32 @@ +# Tasks: Windows Information Protection (WIP) Policies (029) + +**Branch**: `feat/029-wip-policies` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph endpoints for WIP and MDM WIP policy collections. +- [ ] T003 Confirm assignment endpoints and body shape. +- [ ] T004 Confirm patchable fields and define sanitization rules. +- [ ] T005 Decide restore mode and risk classification. + +## Phase 3: Tests (TDD) +- [ ] T006 Add sync test importing both WIP types. +- [ ] T007 Add snapshot capture test (payload + assignments). +- [ ] T008 Add restore preview test (preview-only gating). +- [ ] T009 Add restore execution test using derived endpoints (if enabled). + +## Phase 4: Implementation +- [ ] T010 Add types to `config/tenantpilot.php`. +- [ ] T011 Add contracts in `config/graph_contracts.php`. +- [ ] T012 Update sync classification so WIP types are not treated as generic appProtectionPolicy. +- [ ] T013 Update restore/apply paths if Graph requires derived resources. +- [ ] T014 Add normalizer for readable settings. + +## Phase 5: Verification +- [ ] T015 Run targeted tests. +- [ ] T016 Run Pint (`./vendor/bin/pint --dirty`). + diff --git a/specs/030-intune-rbac-backup/checklists/requirements.md b/specs/030-intune-rbac-backup/checklists/requirements.md new file mode 100644 index 0000000..c18b462 --- /dev/null +++ b/specs/030-intune-rbac-backup/checklists/requirements.md @@ -0,0 +1,12 @@ +# Requirements Checklist (030) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [ ] RBAC types are defined (policy or foundation) with `preview-only` restore. +- [ ] Graph contracts exist for role definitions/assignments. +- [ ] Inventory/backup capture works and is tenant-scoped. +- [ ] Restore preview shows dependency report and blocks unsafe execution. +- [ ] Audit logs exist for preview and any execution attempts. +- [ ] Pest tests cover inventory + backup + preview. + diff --git a/specs/030-intune-rbac-backup/plan.md b/specs/030-intune-rbac-backup/plan.md new file mode 100644 index 0000000..5534f90 --- /dev/null +++ b/specs/030-intune-rbac-backup/plan.md @@ -0,0 +1,24 @@ +# Plan: Intune RBAC Backup (Role Definitions + Assignments) (030) + +**Branch**: `feat/030-intune-rbac-backup` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Confirm Graph API details for RBAC: + - `deviceManagement/roleDefinitions` + - `deviceManagement/roleAssignments` + - required permissions, paging, and any known restrictions +2. Decide modeling: + - policy types (in Policy inventory) vs foundation types (backup-only) +3. Add config/contract entries with restore mode `preview-only`. +4. Implement snapshot capture with careful sanitization (no secrets, no tokens). +5. Implement restore preview dependency checks: + - groups referenced by assignments + - scope tags / scope members +6. Add targeted tests for inventory + backup + preview. + +## Decisions / Notes +- Default to `preview-only` for execution due to high blast radius. +- Prefer mapping by stable identifiers (roleDefinition roleKey/displayName) and treat ambiguity as a block. + diff --git a/specs/030-intune-rbac-backup/spec.md b/specs/030-intune-rbac-backup/spec.md new file mode 100644 index 0000000..d8afa30 --- /dev/null +++ b/specs/030-intune-rbac-backup/spec.md @@ -0,0 +1,51 @@ +# Feature Specification: Intune RBAC Backup (Role Definitions + Assignments) (030) + +**Feature Branch**: `feat/030-intune-rbac-backup` +**Created**: 2026-01-04 +**Status**: Draft +**Priority**: P3 (Optional) + +## Context +For a “complete tenant restore”, RBAC matters. However, RBAC restore is risky and must be **safe-by-default** (preview-only, strong warnings, explicit confirmation, audit logging). + +This feature focuses on: +- Inventory + backup/version of RBAC objects +- Restore preview and validation +- Execution only if/when safety gates and mapping are robust + +## User Scenarios & Testing + +### User Story 1 — Inventory + backup RBAC objects (Priority: P1) +As an admin, I can inventory and back up role definitions and role assignments. + +**Acceptance Scenarios** +1. Sync lists role definitions as `roleDefinition`. +2. Sync lists role assignments as `roleAssignment`. +3. Backup captures full payloads and references (scope tags, members, scopes). + +### User Story 2 — Restore preview + safety gates (Priority: P1) +As an admin, I can run a restore preview that clearly explains what would change and blocks unsafe execution. + +**Acceptance Scenarios** +1. Preview warns on built-in roles vs custom roles and blocks unsafe cases. +2. Preview validates referenced groups/scope tags and reports missing dependencies. + +## Requirements + +### Functional Requirements +- **FR-001**: Add policy (or foundation) types: + - `roleDefinition` → `deviceManagement/roleDefinitions` + - `roleAssignment` → `deviceManagement/roleAssignments` +- **FR-002**: Snapshot capture stores full payloads; assignments capture includes references. +- **FR-003**: Restore preview includes a dependency report (missing groups/tags/scopes). +- **FR-004**: Restore execution defaults to `preview-only` until safety gates are implemented. +- **FR-005**: Add targeted Pest tests for inventory + backup + preview dependency report. + +### Non-Functional Requirements +- **NFR-001**: Never auto-grant permissions/scopes; no “self-heal” background jobs. +- **NFR-002**: All operations are tenant-scoped and audited. + +## Success Criteria +- **SC-001**: RBAC objects are visible and captured in backups. +- **SC-002**: Preview makes restore risk and missing dependencies explicit. + diff --git a/specs/030-intune-rbac-backup/tasks.md b/specs/030-intune-rbac-backup/tasks.md new file mode 100644 index 0000000..5db6013 --- /dev/null +++ b/specs/030-intune-rbac-backup/tasks.md @@ -0,0 +1,29 @@ +# Tasks: Intune RBAC Backup (Role Definitions + Assignments) (030) + +**Branch**: `feat/030-intune-rbac-backup` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [ ] T002 Confirm Graph endpoints, permissions, and payload shape for role definitions/assignments. +- [ ] T003 Decide whether RBAC objects are policy types or foundation types. +- [ ] T004 Define preview dependency report rules and what blocks execution. + +## Phase 3: Tests (TDD) +- [ ] T005 Add sync test importing RBAC objects (if modeled as policy types). +- [ ] T006 Add backup snapshot test for role definitions/assignments. +- [ ] T007 Add restore preview test that reports missing dependencies and blocks execution. + +## Phase 4: Implementation +- [ ] T008 Add RBAC types to `config/tenantpilot.php` (restore mode preview-only). +- [ ] T009 Add graph contracts in `config/graph_contracts.php`. +- [ ] T010 Implement snapshot capture and safe normalized display. +- [ ] T011 Implement restore preview dependency report. + +## Phase 5: Verification +- [ ] T012 Run targeted tests. +- [ ] T013 Run Pint (`./vendor/bin/pint --dirty`). + From 057f2cbeb63a604b5a33e737a3e14c7b8357cc90 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 02:28:29 +0000 Subject: [PATCH 12/18] feat/026-custom-compliance-scripts (#29) tenantpilot.php/graph_contracts.php include the new policy type, Graph contract, and /assign assignment flow (deviceHealthScriptAssignments payload key). ScriptsPolicyNormalizer now supports deviceComplianceScript (more metadata + script display), and InteractsWithODataTypes knows the new type. UI diff view highlights detection-script changes (same logic as other script policies) once tenantpilot.display.show_script_content is enabled. Added regression coverage in tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest plus new feature test DeviceComplianceScriptPolicyTypeTest. Runs: ScriptPoliciesNormalizedDisplayTest.php, ./vendor/bin/pint --dirty. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/29 --- .../Intune/ScriptsPolicyNormalizer.php | 26 +++ .../Concerns/InteractsWithODataTypes.php | 4 + config/graph_contracts.php | 20 ++ config/tenantpilot.php | 10 + .../entries/normalized-diff.blade.php | 2 +- .../DeviceComplianceScriptPolicyTypeTest.php | 186 ++++++++++++++++++ .../ScriptPoliciesNormalizedDisplayTest.php | 72 ++++++- 7 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 tests/Feature/DeviceComplianceScriptPolicyTypeTest.php diff --git a/app/Services/Intune/ScriptsPolicyNormalizer.php b/app/Services/Intune/ScriptsPolicyNormalizer.php index 68bf7e5..172bd8e 100644 --- a/app/Services/Intune/ScriptsPolicyNormalizer.php +++ b/app/Services/Intune/ScriptsPolicyNormalizer.php @@ -11,6 +11,7 @@ public function __construct(private readonly DefaultPolicyNormalizer $defaultNor public function supports(string $policyType): bool { return in_array($policyType, [ + 'deviceComplianceScript', 'deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', @@ -39,6 +40,31 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $entries[] = ['key' => 'Description', 'value' => $description]; } + $fileName = Arr::get($snapshot, 'fileName'); + if (is_string($fileName) && $fileName !== '') { + $entries[] = ['key' => 'File name', 'value' => $fileName]; + } + + $publisher = Arr::get($snapshot, 'publisher'); + if (is_string($publisher) && $publisher !== '') { + $entries[] = ['key' => 'Publisher', 'value' => $publisher]; + } + + $runAsAccount = Arr::get($snapshot, 'runAsAccount'); + if (is_string($runAsAccount) && $runAsAccount !== '') { + $entries[] = ['key' => 'Run as account', 'value' => $runAsAccount]; + } + + $runAs32Bit = Arr::get($snapshot, 'runAs32Bit'); + if (is_bool($runAs32Bit)) { + $entries[] = ['key' => 'Run as 32-bit', 'value' => $runAs32Bit ? 'Enabled' : 'Disabled']; + } + + $enforceSignatureCheck = Arr::get($snapshot, 'enforceSignatureCheck'); + if (is_bool($enforceSignatureCheck)) { + $entries[] = ['key' => 'Enforce signature check', 'value' => $enforceSignatureCheck ? 'Enabled' : 'Disabled']; + } + $entries = array_merge($entries, $this->contentEntries($snapshot)); $schedule = Arr::get($snapshot, 'runSchedule'); diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 37ba8dc..3a2d09b 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -62,6 +62,10 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.deviceHealthScript', 'all' => '#microsoft.graph.deviceHealthScript', ], + 'deviceComplianceScript' => [ + 'windows' => '#microsoft.graph.deviceComplianceScript', + 'all' => '#microsoft.graph.deviceComplianceScript', + ], 'enrollmentRestriction' => [ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration', ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 674e824..c2b9c5c 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -438,6 +438,26 @@ 'id_field' => 'id', 'hydration' => 'properties', ], + 'deviceComplianceScript' => [ + 'resource' => 'deviceManagement/deviceComplianceScripts', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceComplianceScript', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceComplianceScripts/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'deviceHealthScriptAssignments', + 'assignments_update_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/deviceComplianceScripts/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + ], 'deviceManagementScript' => [ 'resource' => 'deviceManagement/deviceManagementScripts', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index d222293..f0f8062 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -155,6 +155,16 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'deviceComplianceScript', + 'label' => 'Custom Compliance Scripts', + 'category' => 'Compliance', + 'platform' => 'windows', + 'endpoint' => 'deviceManagement/deviceComplianceScripts', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'windowsAutopilotDeploymentProfile', 'label' => 'Windows Autopilot Profiles', diff --git a/resources/views/filament/infolists/entries/normalized-diff.blade.php b/resources/views/filament/infolists/entries/normalized-diff.blade.php index a7623b8..0390c23 100644 --- a/resources/views/filament/infolists/entries/normalized-diff.blade.php +++ b/resources/views/filament/infolists/entries/normalized-diff.blade.php @@ -58,7 +58,7 @@ $canHighlightScripts = static function (?string $policyType): bool { return (bool) config('tenantpilot.display.show_script_content', false) - && in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript'], true); + && in_array($policyType, ['deviceManagementScript', 'deviceShellScript', 'deviceHealthScript', 'deviceComplianceScript'], true); }; $selectGrammar = static function (?string $policyType, string $code): string { diff --git a/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php new file mode 100644 index 0000000..c24b180 --- /dev/null +++ b/tests/Feature/DeviceComplianceScriptPolicyTypeTest.php @@ -0,0 +1,186 @@ + + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + 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 $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return array_shift($this->requestResponses) ?? new GraphResponse(true, []); + } +} + +it('includes device compliance scripts in supported policy types', function () { + $supported = collect(config('tenantpilot.supported_policy_types', [])) + ->keyBy('type') + ->all(); + + expect($supported)->toHaveKey('deviceComplianceScript'); + expect($supported['deviceComplianceScript']['endpoint'] ?? null)->toBe('deviceManagement/deviceComplianceScripts'); + expect($supported['deviceComplianceScript']['restore'] ?? null)->toBe('enabled'); +}); + +it('defines device compliance script graph contract with correct assignment payload key', function () { + $contract = config('graph_contracts.types.deviceComplianceScript'); + + expect($contract)->toBeArray(); + expect($contract['resource'] ?? null)->toBe('deviceManagement/deviceComplianceScripts'); + expect($contract['assignments_create_path'] ?? null)->toBe('/deviceManagement/deviceComplianceScripts/{id}/assign'); + expect($contract['assignments_payload_key'] ?? null)->toBe('deviceHealthScriptAssignments'); +}); + +it('restores device compliance script assignments via assign action', function () { + $client = new DeviceComplianceScriptRestoreGraphClient( + applyPolicyResponse: new GraphResponse(true, []), + requestResponses: [ + new GraphResponse(true, []), // assign action + ], + ); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-1', + ]); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'dcs-1', + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::factory()->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' => [ + 'id' => $policy->external_id, + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + ], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + ], + ); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/deviceComplianceScripts/dcs-1/assign'); + + $payloadAssignments = $postCalls[0]['payload']['deviceHealthScriptAssignments'] ?? []; + $groupIds = collect($payloadAssignments)->pluck('target.groupId')->all(); + + expect($groupIds)->toBe(['target-group-1']); + expect($payloadAssignments[0])->not->toHaveKey('id'); +}); + +it('normalizes device compliance script key fields', function () { + config([ + 'tenantpilot.display.show_script_content' => false, + ]); + + $normalized = app(PolicyNormalizer::class)->normalize([ + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + 'displayName' => 'My script', + 'runAsAccount' => 'system', + 'runAs32Bit' => true, + 'enforceSignatureCheck' => false, + 'detectionScriptContent' => base64_encode("Write-Host 'hello'\n"), + ], 'deviceComplianceScript', 'windows'); + + $settings = $normalized['settings'][0]['entries'] ?? []; + $byKey = collect($settings)->keyBy('key'); + + expect($byKey['Run as account']['value'] ?? null)->toBe('system'); + expect($byKey['Run as 32-bit']['value'] ?? null)->toBe('Enabled'); + expect($byKey['Enforce signature check']['value'] ?? null)->toBe('Disabled'); +}); diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index 9db3154..7673fe7 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -35,6 +35,10 @@ $scriptContent = "#!/bin/zsh\n".str_repeat('X', 20); } + $contentKey = in_array($policyType, ['deviceHealthScript', 'deviceComplianceScript'], true) + ? 'detectionScriptContent' + : 'scriptContent'; + $version = PolicyVersion::factory()->create([ 'policy_id' => $policy->id, 'tenant_id' => $tenant->id, @@ -43,7 +47,7 @@ '@odata.type' => $odataType, 'displayName' => 'Script policy', 'description' => 'desc', - 'scriptContent' => $scriptContent, + $contentKey => $contentKey === 'scriptContent' ? $scriptContent : base64_encode($scriptContent), ], ]); @@ -60,6 +64,7 @@ ['deviceManagementScript', '#microsoft.graph.deviceManagementScript'], ['deviceShellScript', '#microsoft.graph.deviceShellScript'], ['deviceHealthScript', '#microsoft.graph.deviceHealthScript'], + ['deviceComplianceScript', '#microsoft.graph.deviceComplianceScript'], ]); it('renders diff tab with highlighted script content for script policies', function () { @@ -126,3 +131,68 @@ ? putenv("INTUNE_TENANT_ID={$originalEnv}") : putenv('INTUNE_TENANT_ID'); }); + +it('renders diff tab with highlighted script content for device compliance scripts', function () { + $originalEnv = getenv('INTUNE_TENANT_ID'); + putenv('INTUNE_TENANT_ID='); + + $this->actingAs(User::factory()->create()); + + config([ + 'tenantpilot.display.show_script_content' => true, + 'tenantpilot.display.max_script_content_chars' => 5000, + ]); + + $tenant = Tenant::factory()->create(); + putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + ]); + + $scriptOne = "# test\n".str_repeat("Write-Host 'one'\n", 40); + $scriptTwo = "# test\n".str_repeat("Write-Host 'two'\n", 40); + + $v1 = PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 1, + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + 'displayName' => 'My compliance script', + 'detectionScriptContent' => base64_encode($scriptOne), + ], + ]); + + $v2 = PolicyVersion::factory()->create([ + 'policy_id' => $policy->getKey(), + 'tenant_id' => $tenant->getKey(), + 'version_number' => 2, + 'policy_type' => 'deviceComplianceScript', + 'platform' => 'windows', + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceComplianceScript', + 'displayName' => 'My compliance script', + 'detectionScriptContent' => base64_encode($scriptTwo), + ], + ]); + + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); + + $this->get($url.'?tab=diff') + ->assertSuccessful() + ->assertSeeText('Fullscreen') + ->assertSeeText("- Write-Host 'one'") + ->assertSeeText("+ Write-Host 'two'") + ->assertSee('bg-danger-50', false) + ->assertSee('bg-success-50', false); + + $originalEnv !== false + ? putenv("INTUNE_TENANT_ID={$originalEnv}") + : putenv('INTUNE_TENANT_ID'); +}); From 83f18142540160da2e13b61913ad5cc213d44be1 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 03:01:11 +0000 Subject: [PATCH 13/18] feat/024-terms-and-conditions (#30) Added termsAndConditions to the supported policy list and Graph contract so Intune sync/backup/restore paths (and scope tag handling) treat Terms & Conditions like other enrollment policies, ensuring listings, snapshots, assignments CRUD, and restore modes flow naturally (tenantpilot.php (lines 168-225), graph_contracts.php (lines 520-560), InteractsWithODataTypes.php (lines 10-30)). Exposed a dedicated TermsAndConditionsNormalizer and tagged it in AppServiceProvider so the Filament UI shows readable rows (display name, title, acceptance statement, body, scope tags) and the diff engine flattens them consistently (TermsAndConditionsNormalizer.php (lines 1-94), AppServiceProvider.php (lines 43-58)). Added Pest coverage for the new type that checks config/contract entries, assignment restore behavior, normalized output, and PolicySync ingestion (TermsAndConditionsPolicyTypeTest.php (lines 70-200)). Tests: TermsAndConditionsPolicyTypeTest.php ./vendor/bin/pint --dirty Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/30 --- app/Providers/AppServiceProvider.php | 2 + .../Intune/TermsAndConditionsNormalizer.php | 94 ++++++++ .../Concerns/InteractsWithODataTypes.php | 4 + config/graph_contracts.php | 42 ++++ config/tenantpilot.php | 10 + .../TermsAndConditionsPolicyTypeTest.php | 218 ++++++++++++++++++ 6 files changed, 370 insertions(+) create mode 100644 app/Services/Intune/TermsAndConditionsNormalizer.php create mode 100644 tests/Feature/TermsAndConditionsPolicyTypeTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b5c7c32..fbe5206 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Services\Intune\ManagedDeviceAppConfigurationNormalizer; use App\Services\Intune\ScriptsPolicyNormalizer; use App\Services\Intune\SettingsCatalogPolicyNormalizer; +use App\Services\Intune\TermsAndConditionsNormalizer; use App\Services\Intune\WindowsDriverUpdateProfileNormalizer; use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; @@ -50,6 +51,7 @@ public function register(): void ManagedDeviceAppConfigurationNormalizer::class, ScriptsPolicyNormalizer::class, SettingsCatalogPolicyNormalizer::class, + TermsAndConditionsNormalizer::class, WindowsDriverUpdateProfileNormalizer::class, WindowsFeatureUpdateProfileNormalizer::class, WindowsQualityUpdateProfileNormalizer::class, diff --git a/app/Services/Intune/TermsAndConditionsNormalizer.php b/app/Services/Intune/TermsAndConditionsNormalizer.php new file mode 100644 index 0000000..9af263e --- /dev/null +++ b/app/Services/Intune/TermsAndConditionsNormalizer.php @@ -0,0 +1,94 @@ +>, warnings: array} + */ + public function normalize(?array $snapshot, string $policyType, ?string $platform = null): array + { + $snapshot = is_array($snapshot) ? $snapshot : []; + $entries = []; + + $this->pushEntry($entries, 'Display name', Arr::get($snapshot, 'displayName')); + $this->pushEntry($entries, 'Title', Arr::get($snapshot, 'title')); + $this->pushEntry($entries, 'Description', Arr::get($snapshot, 'description')); + $this->pushEntry($entries, 'Acceptance statement', Arr::get($snapshot, 'acceptanceStatement')); + $this->pushEntry($entries, 'Body text', $this->limitText(Arr::get($snapshot, 'bodyText'))); + $this->pushEntry($entries, 'Version', Arr::get($snapshot, 'version')); + + $roleScopeTagIds = Arr::get($snapshot, 'roleScopeTagIds'); + if (is_array($roleScopeTagIds) && $roleScopeTagIds !== []) { + $this->pushEntry($entries, 'Scope tag IDs', array_values($roleScopeTagIds)); + } + + if ($entries === []) { + return [ + 'status' => 'warning', + 'settings' => [], + 'warnings' => ['Terms & Conditions snapshot contains no readable fields.'], + ]; + } + + return [ + 'status' => 'ok', + 'settings' => [ + [ + 'type' => 'keyValue', + 'title' => 'Terms & Conditions', + 'entries' => $entries, + ], + ], + 'warnings' => [], + ]; + } + + public function flattenForDiff(?array $snapshot, string $policyType, ?string $platform = null): array + { + $normalized = $this->normalize($snapshot ?? [], $policyType, $platform); + + return $this->defaultNormalizer->flattenNormalizedForDiff($normalized); + } + + /** + * @param array> $entries + */ + private function pushEntry(array &$entries, string $key, mixed $value): void + { + if ($value === null) { + return; + } + + if (is_string($value) && $value === '') { + return; + } + + $entries[] = [ + 'key' => $key, + 'value' => $value, + ]; + } + + private function limitText(mixed $value): mixed + { + if (! is_string($value)) { + return $value; + } + + return Str::limit($value, 1000); + } +} diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index 3a2d09b..bc9ddc3 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -62,6 +62,10 @@ protected static function odataTypeMap(): array 'windows' => '#microsoft.graph.deviceHealthScript', 'all' => '#microsoft.graph.deviceHealthScript', ], + 'termsAndConditions' => [ + 'windows' => '#microsoft.graph.termsAndConditions', + 'all' => '#microsoft.graph.termsAndConditions', + ], 'deviceComplianceScript' => [ 'windows' => '#microsoft.graph.deviceComplianceScript', 'all' => '#microsoft.graph.deviceComplianceScript', diff --git a/config/graph_contracts.php b/config/graph_contracts.php index c2b9c5c..5abff69 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -536,6 +536,48 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], + 'termsAndConditions' => [ + 'resource' => 'deviceManagement/termsAndConditions', + 'allowed_select' => [ + 'id', + 'displayName', + 'description', + 'title', + 'bodyText', + 'acceptanceStatement', + 'version', + 'roleScopeTagIds', + 'lastModifiedDateTime', + 'createdDateTime', + ], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.termsAndConditions', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'createdDateTime', + 'lastModifiedDateTime', + 'modifiedDateTime', + 'version', + 'acceptanceStatuses', + 'assignments', + 'groupAssignments', + ], + 'assignments_list_path' => '/deviceManagement/termsAndConditions/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/termsAndConditions/{id}/assignments', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'termsAndConditionsAssignments', + 'assignments_update_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}', + 'assignments_update_method' => 'PATCH', + 'assignments_delete_path' => '/deviceManagement/termsAndConditions/{id}/assignments/{assignmentId}', + 'assignments_delete_method' => 'DELETE', + 'supports_scope_tags' => true, + 'scope_tag_field' => 'roleScopeTagIds', + ], 'windowsAutopilotDeploymentProfile' => [ 'resource' => 'deviceManagement/windowsAutopilotDeploymentProfiles', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'lastModifiedDateTime'], diff --git a/config/tenantpilot.php b/config/tenantpilot.php index f0f8062..85cddb9 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -195,6 +195,16 @@ 'restore' => 'preview-only', 'risk' => 'high', ], + [ + 'type' => 'termsAndConditions', + 'label' => 'Terms & Conditions', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/termsAndConditions', + 'backup' => 'full', + 'restore' => 'enabled', + 'risk' => 'medium-high', + ], [ 'type' => 'endpointSecurityIntent', 'label' => 'Endpoint Security Intents', diff --git a/tests/Feature/TermsAndConditionsPolicyTypeTest.php b/tests/Feature/TermsAndConditionsPolicyTypeTest.php new file mode 100644 index 0000000..84cce93 --- /dev/null +++ b/tests/Feature/TermsAndConditionsPolicyTypeTest.php @@ -0,0 +1,218 @@ + + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + 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 $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return array_shift($this->requestResponses) ?? new GraphResponse(true, []); + } +} + +it('includes terms and conditions policy type in supported types', function () { + $byType = collect(config('tenantpilot.supported_policy_types', [])) + ->keyBy('type'); + + expect($byType)->toHaveKey('termsAndConditions'); + expect($byType['termsAndConditions']['endpoint'] ?? null)->toBe('deviceManagement/termsAndConditions'); +}); + +it('defines terms and conditions graph contract with assignments paths', function () { + $contract = config('graph_contracts.types.termsAndConditions'); + + expect($contract)->toBeArray(); + expect($contract['resource'] ?? null)->toBe('deviceManagement/termsAndConditions'); + expect($contract['assignments_list_path'] ?? null)->toBe('/deviceManagement/termsAndConditions/{id}/assignments'); + expect($contract['assignments_payload_key'] ?? null)->toBe('termsAndConditionsAssignments'); +}); + +it('restores terms and conditions assignments via assignments endpoint', function () { + $client = new TermsAndConditionsRestoreGraphClient( + applyPolicyResponse: new GraphResponse(true, []), + requestResponses: [ + new GraphResponse(true, ['value' => []]), // existing assignments list + new GraphResponse(true, []), // create assignments + ], + ); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-1']); + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'tc-1', + 'policy_type' => 'termsAndConditions', + 'platform' => 'all', + ]); + + $backupSet = \App\Models\BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = \App\Models\BackupItem::factory()->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' => [ + 'id' => $policy->external_id, + '@odata.type' => '#microsoft.graph.termsAndConditions', + ], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + ], + ); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/termsAndConditions/tc-1/assignments'); + + $payload = $postCalls[0]['payload'] ?? []; + expect($payload['target']['groupId'] ?? null)->toBe('target-group-1'); +}); + +it('normalizes terms and conditions key fields', function () { + $normalized = app(PolicyNormalizer::class)->normalize([ + '@odata.type' => '#microsoft.graph.termsAndConditions', + 'displayName' => 'Terms and Conditions Alpha', + 'title' => 'Alpha terms', + 'description' => 'Long form description', + 'acceptanceStatement' => 'I agree', + 'bodyText' => str_repeat('Line.', 100), + 'version' => 3, + 'roleScopeTagIds' => ['0', '1'], + ], 'termsAndConditions', 'all'); + + $entries = $normalized['settings'][0]['entries'] ?? []; + $byKey = collect($entries)->keyBy('key'); + + expect($byKey['Display name']['value'] ?? null)->toBe('Terms and Conditions Alpha'); + expect($byKey['Title']['value'] ?? null)->toBe('Alpha terms'); + expect($byKey['Acceptance statement']['value'] ?? null)->toBe('I agree'); + expect($byKey['Version']['value'] ?? null)->toBe(3); + expect($byKey['Scope tag IDs']['value'] ?? null)->toBe(['0', '1']); +}); + +it('syncs terms and conditions from graph', function () { + $tenant = Tenant::factory()->create(['status' => 'active']); + $logger = mock(GraphLogger::class); + + $logger->shouldReceive('logRequest') + ->zeroOrMoreTimes() + ->andReturnNull(); + + $logger->shouldReceive('logResponse') + ->zeroOrMoreTimes() + ->andReturnNull(); + + mock(GraphClientInterface::class) + ->shouldReceive('listPolicies') + ->once() + ->with('termsAndConditions', mockery::type('array')) + ->andReturn(new GraphResponse( + success: true, + data: [ + [ + 'id' => 'tc-1', + 'displayName' => 'T&C', + '@odata.type' => '#microsoft.graph.termsAndConditions', + ], + ], + )); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'termsAndConditions', 'platform' => 'all'], + ]); + + expect(Policy::query()->where('tenant_id', $tenant->id)->where('policy_type', 'termsAndConditions')->count()) + ->toBe(1); +}); From 817ad208da9ee72f74ce2e966ec58fcca9b45a64 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 13:25:15 +0000 Subject: [PATCH 14/18] feat/027-enrollment-config-subtypes (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expose enrollment config subtypes as their own policy types (limit/platform restrictions/notifications) with preview-only restore risk and proper Graph contracts classify enrollment configs by their @odata.type + deviceEnrollmentConfigurationType so sync only keeps ESP in windowsEnrollmentStatusPage and the rest stay in their own types, including new restore-normalizer UI blocks + warnings hydrate enrollment notifications: snapshot fetch now downloads each notification template + localized messages, normalized view surfaces template names/subjects/messages, and restore previews keep preview-only behavior tenant UI tweaks: Tenant list and detail actions moved into an action group; “Open in Entra” re-added in index, and detail now has “Deactivate” + tests covering the new menu layout and actions tests added/updated for sync, snapshots, restores, normalized settings, tenant UI, plus Pint/test suite run Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/31 --- app/Filament/Resources/TenantResource.php | 6 + .../TenantResource/Pages/ViewTenant.php | 86 ++++-- .../EnrollmentAutopilotPolicyNormalizer.php | 278 ++++++++++++++++++ app/Services/Intune/PolicySnapshotService.php | 129 ++++++++ app/Services/Intune/PolicySyncService.php | 94 +++++- .../Concerns/InteractsWithODataTypes.php | 9 + config/graph_contracts.php | 52 +++- config/tenantpilot.php | 31 ++ specs/027-enrollment-config-subtypes/tasks.md | 24 +- ...EnrollmentAutopilotSettingsDisplayTest.php | 10 +- .../EnrollmentRestrictionsPreviewOnlyTest.php | 100 +++++++ .../Filament/PolicyVersionSettingsTest.php | 92 ++++++ tests/Feature/Filament/TenantSetupTest.php | 32 ++ ...rollmentConfigurationTypeCollisionTest.php | 107 ++++++- .../VersionCaptureWithAssignmentsTest.php | 70 +++++ tests/Unit/PolicyNormalizerTest.php | 22 +- tests/Unit/PolicySnapshotServiceTest.php | 155 ++++++++++ 17 files changed, 1230 insertions(+), 67 deletions(-) diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index dd61427..92f5156 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -157,6 +157,12 @@ public static function table(Table $table): Table ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => static::entraUrl($record)) + ->visible(fn (Tenant $record) => static::entraUrl($record) !== null) + ->openUrlInNewTab(), Actions\Action::make('verify') ->label('Verify configuration') ->icon('heroicon-o-check-badge') diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index cad7dac..d65f9d9 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -9,6 +9,7 @@ use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantPermissionService; use Filament\Actions; +use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; class ViewTenant extends ViewRecord @@ -18,34 +19,63 @@ class ViewTenant extends ViewRecord protected function getHeaderActions(): array { return [ - Actions\EditAction::make(), - Actions\Action::make('admin_consent') - ->label('Admin consent') - ->icon('heroicon-o-clipboard-document') - ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) - ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) - ->openUrlInNewTab(), - Actions\Action::make('open_in_entra') - ->label('Open in Entra') - ->icon('heroicon-o-arrow-top-right-on-square') - ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) - ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) - ->openUrlInNewTab(), - Actions\Action::make('verify') - ->label('Verify configuration') - ->icon('heroicon-o-check-badge') - ->color('primary') - ->requiresConfirmation() - ->action(function ( - Tenant $record, - TenantConfigService $configService, - TenantPermissionService $permissionService, - RbacHealthService $rbacHealthService, - AuditLogger $auditLogger - ) { - TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); - }), - TenantResource::rbacAction(), + Actions\ActionGroup::make([ + Actions\EditAction::make(), + Actions\Action::make('admin_consent') + ->label('Admin consent') + ->icon('heroicon-o-clipboard-document') + ->url(fn (Tenant $record) => TenantResource::adminConsentUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::adminConsentUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('open_in_entra') + ->label('Open in Entra') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (Tenant $record) => TenantResource::entraUrl($record)) + ->visible(fn (Tenant $record) => TenantResource::entraUrl($record) !== null) + ->openUrlInNewTab(), + Actions\Action::make('verify') + ->label('Verify configuration') + ->icon('heroicon-o-check-badge') + ->color('primary') + ->requiresConfirmation() + ->action(function ( + Tenant $record, + TenantConfigService $configService, + TenantPermissionService $permissionService, + RbacHealthService $rbacHealthService, + AuditLogger $auditLogger + ) { + TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); + }), + TenantResource::rbacAction(), + Actions\Action::make('archive') + ->label('Deactivate') + ->color('danger') + ->icon('heroicon-o-archive-box-x-mark') + ->requiresConfirmation() + ->visible(fn (Tenant $record) => ! $record->trashed()) + ->action(function (Tenant $record, AuditLogger $auditLogger) { + $record->delete(); + + $auditLogger->log( + tenant: $record, + action: 'tenant.archived', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]] + ); + + Notification::make() + ->title('Tenant deactivated') + ->body('The tenant has been archived and hidden from lists.') + ->success() + ->send(); + }), + ]) + ->label('Actions') + ->icon('heroicon-o-ellipsis-vertical') + ->color('gray'), ]; } } diff --git a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php index 9177392..ab73397 100644 --- a/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php +++ b/app/Services/Intune/EnrollmentAutopilotPolicyNormalizer.php @@ -15,6 +15,9 @@ public function supports(string $policyType): bool 'windowsAutopilotDeploymentProfile', 'windowsEnrollmentStatusPage', 'enrollmentRestriction', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', ], true); } @@ -34,6 +37,18 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor $warnings[] = 'Restore is preview-only for Enrollment Restrictions.'; } + if ($policyType === 'deviceEnrollmentLimitConfiguration') { + $warnings[] = 'Restore is preview-only for Enrollment Limits.'; + } + + if ($policyType === 'deviceEnrollmentPlatformRestrictionsConfiguration') { + $warnings[] = 'Restore is preview-only for Platform Restrictions.'; + } + + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + $warnings[] = 'Restore is preview-only for Enrollment Notifications.'; + } + $generalEntries = [ ['key' => 'Type', 'value' => $policyType], ]; @@ -68,6 +83,9 @@ public function normalize(?array $snapshot, string $policyType, ?string $platfor 'windowsAutopilotDeploymentProfile' => $this->buildAutopilotBlock($snapshot), 'windowsEnrollmentStatusPage' => $this->buildEnrollmentStatusPageBlock($snapshot), 'enrollmentRestriction' => $this->buildEnrollmentRestrictionBlock($snapshot), + 'deviceEnrollmentLimitConfiguration' => $this->buildEnrollmentLimitBlock($snapshot), + 'deviceEnrollmentPlatformRestrictionsConfiguration' => $this->buildEnrollmentPlatformRestrictionsBlock($snapshot), + 'deviceEnrollmentNotificationConfiguration' => $this->buildEnrollmentNotificationBlock($snapshot), default => null, }; @@ -319,6 +337,266 @@ private function buildEnrollmentRestrictionBlock(array $snapshot): ?array ]; } + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentLimitBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'limit' => 'Enrollment limit', + 'limitType' => 'Limit type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment limits', + 'entries' => $entries, + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentPlatformRestrictionsBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'platformType' => 'Platform type', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } + } + + $platformPayload = Arr::get($snapshot, 'platformRestrictions') ?? Arr::get($snapshot, 'platformRestriction'); + if (is_array($platformPayload) && $platformPayload !== []) { + $prefix = (string) (Arr::get($snapshot, 'platformType') ?: 'Platform'); + $this->appendPlatformRestrictionEntries($entries, $prefix, $platformPayload); + } + + $typedRestrictions = [ + 'androidForWorkRestriction' => 'Android work profile', + 'androidRestriction' => 'Android', + 'iosRestriction' => 'iOS/iPadOS', + 'macRestriction' => 'macOS', + 'windowsRestriction' => 'Windows', + ]; + + foreach ($typedRestrictions as $key => $prefix) { + $restriction = Arr::get($snapshot, $key); + + if (! is_array($restriction) || $restriction === []) { + continue; + } + + $this->appendPlatformRestrictionEntries($entries, $prefix, $restriction); + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Platform restrictions (enrollment)', + 'entries' => $entries, + ]; + } + + /** + * @param array $entries + */ + private function appendPlatformRestrictionEntries(array &$entries, string $prefix, array $payload): void + { + $payload = Arr::except($payload, ['@odata.type']); + + $platformBlocked = Arr::get($payload, 'platformBlocked'); + if (is_bool($platformBlocked)) { + $entries[] = ['key' => "{$prefix}: Platform blocked", 'value' => $platformBlocked ? 'Enabled' : 'Disabled']; + } + + $personalBlocked = Arr::get($payload, 'personalDeviceEnrollmentBlocked'); + if (is_bool($personalBlocked)) { + $entries[] = ['key' => "{$prefix}: Personal device enrollment blocked", 'value' => $personalBlocked ? 'Enabled' : 'Disabled']; + } + + $osMin = Arr::get($payload, 'osMinimumVersion'); + $entries[] = [ + 'key' => "{$prefix}: OS minimum version", + 'value' => (is_string($osMin) && $osMin !== '') ? $osMin : 'None', + ]; + + $osMax = Arr::get($payload, 'osMaximumVersion'); + $entries[] = [ + 'key' => "{$prefix}: OS maximum version", + 'value' => (is_string($osMax) && $osMax !== '') ? $osMax : 'None', + ]; + + $blockedManufacturers = Arr::get($payload, 'blockedManufacturers'); + $entries[] = [ + 'key' => "{$prefix}: Blocked manufacturers", + 'value' => (is_array($blockedManufacturers) && $blockedManufacturers !== []) + ? array_values($blockedManufacturers) + : ['None'], + ]; + + $blockedSkus = Arr::get($payload, 'blockedSkus'); + $entries[] = [ + 'key' => "{$prefix}: Blocked SKUs", + 'value' => (is_array($blockedSkus) && $blockedSkus !== []) + ? array_values($blockedSkus) + : ['None'], + ]; + } + + /** + * @return array{type: string, title: string, entries: array}|null + */ + private function buildEnrollmentNotificationBlock(array $snapshot): ?array + { + $entries = []; + + foreach ([ + 'priority' => 'Priority', + 'version' => 'Version', + 'platformType' => 'Platform type', + 'deviceEnrollmentConfigurationType' => 'Configuration type', + 'brandingOptions' => 'Branding options', + 'templateType' => 'Template type', + 'defaultLocale' => 'Default locale', + 'notificationMessageTemplateId' => 'Notification message template ID', + ] as $key => $label) { + $value = Arr::get($snapshot, $key); + + if (is_int($value) || is_float($value)) { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_string($value) && $value !== '') { + $entries[] = ['key' => $label, 'value' => $value]; + } elseif (is_bool($value)) { + $entries[] = ['key' => $label, 'value' => $value ? 'Enabled' : 'Disabled']; + } + } + + $notificationTemplates = Arr::get($snapshot, 'notificationTemplates'); + if (is_array($notificationTemplates) && $notificationTemplates !== []) { + $entries[] = ['key' => 'Notification templates', 'value' => array_values($notificationTemplates)]; + } + + $templateSnapshots = Arr::get($snapshot, 'notificationTemplateSnapshots'); + if (is_array($templateSnapshots) && $templateSnapshots !== []) { + foreach ($templateSnapshots as $templateSnapshot) { + if (! is_array($templateSnapshot)) { + continue; + } + + $channel = Arr::get($templateSnapshot, 'channel'); + $channelLabel = is_string($channel) && $channel !== '' ? $channel : 'Template'; + + $templateId = Arr::get($templateSnapshot, 'template_id'); + if (is_string($templateId) && $templateId !== '') { + $entries[] = ['key' => "{$channelLabel} template ID", 'value' => $templateId]; + } + + $template = Arr::get($templateSnapshot, 'template'); + if (is_array($template) && $template !== []) { + $displayName = Arr::get($template, 'displayName'); + if (is_string($displayName) && $displayName !== '') { + $entries[] = ['key' => "{$channelLabel} template name", 'value' => $displayName]; + } + + $brandingOptions = Arr::get($template, 'brandingOptions'); + if (is_string($brandingOptions) && $brandingOptions !== '') { + $entries[] = ['key' => "{$channelLabel} branding options", 'value' => $brandingOptions]; + } + + $defaultLocale = Arr::get($template, 'defaultLocale'); + if (is_string($defaultLocale) && $defaultLocale !== '') { + $entries[] = ['key' => "{$channelLabel} default locale", 'value' => $defaultLocale]; + } + } + + $localizedMessages = Arr::get($templateSnapshot, 'localized_notification_messages'); + if (is_array($localizedMessages) && $localizedMessages !== []) { + foreach ($localizedMessages as $localizedMessage) { + if (! is_array($localizedMessage)) { + continue; + } + + $locale = Arr::get($localizedMessage, 'locale'); + $localeLabel = is_string($locale) && $locale !== '' ? $locale : 'locale'; + + $subject = Arr::get($localizedMessage, 'subject'); + if (is_string($subject) && $subject !== '') { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Subject", 'value' => $subject]; + } + + $messageTemplate = Arr::get($localizedMessage, 'messageTemplate'); + if (is_string($messageTemplate) && $messageTemplate !== '') { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Message", 'value' => $messageTemplate]; + } + + $isDefault = Arr::get($localizedMessage, 'isDefault'); + if (is_bool($isDefault)) { + $entries[] = ['key' => "{$channelLabel} ({$localeLabel}) Default", 'value' => $isDefault ? 'Enabled' : 'Disabled']; + } + } + } + } + } + + $assigned = Arr::get($snapshot, 'assignments'); + if (is_array($assigned) && $assigned !== []) { + $entries[] = ['key' => 'Assignments (snapshot)', 'value' => '[present]']; + } + + if ($entries === []) { + return null; + } + + return [ + 'type' => 'keyValue', + 'title' => 'Enrollment notifications', + 'entries' => $entries, + ]; + } + /** * @return array */ diff --git a/app/Services/Intune/PolicySnapshotService.php b/app/Services/Intune/PolicySnapshotService.php index 33572eb..aa3c57e 100644 --- a/app/Services/Intune/PolicySnapshotService.php +++ b/app/Services/Intune/PolicySnapshotService.php @@ -124,6 +124,15 @@ public function fetch(Tenant $tenant, Policy $policy, ?string $actorEmail = null ); } + if ($policy->policy_type === 'deviceEnrollmentNotificationConfiguration') { + [$payload, $metadata] = $this->hydrateEnrollmentNotificationTemplates( + tenantIdentifier: $tenantIdentifier, + tenant: $tenant, + payload: is_array($payload) ? $payload : [], + metadata: $metadata + ); + } + if ($response->failed()) { $reason = $this->formatGraphFailureReason($response); @@ -607,6 +616,126 @@ private function hydrateComplianceActions(string $tenantIdentifier, Tenant $tena return [$payload, $metadata]; } + /** + * Hydrate enrollment notifications with message template details. + * + * @return array{0:array,1:array} + */ + private function hydrateEnrollmentNotificationTemplates(string $tenantIdentifier, Tenant $tenant, array $payload, array $metadata): array + { + $existing = $payload['notificationTemplateSnapshots'] ?? null; + + if (is_array($existing) && $existing !== []) { + $metadata['enrollment_notification_templates_hydration'] = 'embedded'; + + return [$payload, $metadata]; + } + + $templateRefs = $payload['notificationTemplates'] ?? null; + + if (! is_array($templateRefs) || $templateRefs === []) { + $metadata['enrollment_notification_templates_hydration'] = 'none'; + + return [$payload, $metadata]; + } + + $options = [ + 'tenant' => $tenantIdentifier, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]; + + $snapshots = []; + $failures = 0; + + foreach ($templateRefs as $templateRef) { + if (! is_string($templateRef) || $templateRef === '') { + continue; + } + + [$channel, $templateId] = $this->parseEnrollmentNotificationTemplateRef($templateRef); + + if ($templateId === null) { + $failures++; + + continue; + } + + $templatePath = sprintf('deviceManagement/notificationMessageTemplates/%s', urlencode($templateId)); + $templateResponse = $this->graphClient->request('GET', $templatePath, $options); + + if ($templateResponse->failed() || ! is_array($templateResponse->data)) { + $failures++; + + continue; + } + + $template = Arr::except($templateResponse->data, ['@odata.context']); + + $messagesPath = sprintf( + 'deviceManagement/notificationMessageTemplates/%s/localizedNotificationMessages', + urlencode($templateId) + ); + $messagesResponse = $this->graphClient->request('GET', $messagesPath, $options); + + $messages = []; + + if ($messagesResponse->failed()) { + $failures++; + } else { + $pageItems = $messagesResponse->data['value'] ?? []; + + if (is_array($pageItems)) { + foreach ($pageItems as $message) { + if (is_array($message)) { + $messages[] = Arr::except($message, ['@odata.context']); + } + } + } + } + + $snapshots[] = [ + 'channel' => $channel, + 'template_id' => $templateId, + 'template' => $template, + 'localized_notification_messages' => $messages, + ]; + } + + if ($snapshots === []) { + $metadata['enrollment_notification_templates_hydration'] = 'failed'; + + return [$payload, $metadata]; + } + + $payload['notificationTemplateSnapshots'] = $snapshots; + + $metadata['enrollment_notification_templates_hydration'] = $failures > 0 ? 'partial' : 'complete'; + + return [$payload, $metadata]; + } + + /** + * @return array{0:?string,1:?string} + */ + private function parseEnrollmentNotificationTemplateRef(string $templateRef): array + { + if (! str_contains($templateRef, '_')) { + return [null, $templateRef]; + } + + [$channel, $templateId] = explode('_', $templateRef, 2); + + $channel = trim($channel); + $templateId = trim($templateId); + + if ($templateId === '') { + return [$channel !== '' ? $channel : null, null]; + } + + return [$channel !== '' ? $channel : null, $templateId]; + } + /** * Extract all settingDefinitionId from settings array, including nested children. */ diff --git a/app/Services/Intune/PolicySyncService.php b/app/Services/Intune/PolicySyncService.php index 1f87f19..ec42c31 100644 --- a/app/Services/Intune/PolicySyncService.php +++ b/app/Services/Intune/PolicySyncService.php @@ -167,7 +167,15 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat return $this->resolveConfigurationPolicyType($policyData); } - if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + $enrollmentConfigurationTypes = [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', + ]; + + if (! in_array($policyType, $enrollmentConfigurationTypes, true)) { return $policyType; } @@ -175,6 +183,18 @@ private function resolveCanonicalPolicyType(string $policyType, array $policyDat return 'windowsEnrollmentStatusPage'; } + if ($this->isEnrollmentNotificationItem($policyData)) { + return 'deviceEnrollmentNotificationConfiguration'; + } + + if ($this->isEnrollmentLimitItem($policyData)) { + return 'deviceEnrollmentLimitConfiguration'; + } + + if ($this->isEnrollmentPlatformRestrictionsItem($policyData)) { + return 'deviceEnrollmentPlatformRestrictionsConfiguration'; + } + return 'enrollmentRestriction'; } @@ -255,13 +275,77 @@ private function isEnrollmentStatusPageItem(array $policyData): bool || (is_string($configurationType) && $configurationType === 'windows10EnrollmentCompletionPageConfiguration'); } - private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + private function isEnrollmentLimitItem(array $policyData): bool { - if (! in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { - return; + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + return (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentLimitConfiguration') === 0) + || (is_string($configurationType) && strcasecmp($configurationType, 'deviceEnrollmentLimitConfiguration') === 0); + } + + private function isEnrollmentPlatformRestrictionsItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + if (is_string($odataType) && $odataType !== '') { + $odataTypeKey = strtolower($odataType); + + if (in_array($odataTypeKey, [ + '#microsoft.graph.deviceenrollmentplatformrestrictionconfiguration', + '#microsoft.graph.deviceenrollmentplatformrestrictionsconfiguration', + ], true)) { + return true; + } } - $enrollmentTypes = ['enrollmentRestriction', 'windowsEnrollmentStatusPage']; + if (is_string($configurationType) && $configurationType !== '') { + $configurationTypeKey = strtolower($configurationType); + + if (in_array($configurationTypeKey, [ + 'deviceenrollmentplatformrestrictionconfiguration', + 'deviceenrollmentplatformrestrictionsconfiguration', + ], true)) { + return true; + } + } + + return false; + } + + private function isEnrollmentNotificationItem(array $policyData): bool + { + $odataType = $policyData['@odata.type'] ?? $policyData['@OData.Type'] ?? null; + $configurationType = $policyData['deviceEnrollmentConfigurationType'] ?? null; + + if (is_string($odataType) && strcasecmp($odataType, '#microsoft.graph.deviceEnrollmentNotificationConfiguration') === 0) { + return true; + } + + if (! is_string($configurationType) || $configurationType === '') { + return false; + } + + return in_array(strtolower($configurationType), [ + 'enrollmentnotificationsconfiguration', + 'deviceenrollmentnotificationconfiguration', + ], true); + } + + private function reclassifyEnrollmentConfigurationPoliciesIfNeeded(int $tenantId, string $externalId, string $policyType): void + { + $enrollmentTypes = [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentNotificationConfiguration', + ]; + + if (! in_array($policyType, $enrollmentTypes, true)) { + return; + } $existingCorrect = Policy::query() ->where('tenant_id', $tenantId) diff --git a/app/Support/Concerns/InteractsWithODataTypes.php b/app/Support/Concerns/InteractsWithODataTypes.php index bc9ddc3..5ce7c3e 100644 --- a/app/Support/Concerns/InteractsWithODataTypes.php +++ b/app/Support/Concerns/InteractsWithODataTypes.php @@ -73,6 +73,15 @@ protected static function odataTypeMap(): array 'enrollmentRestriction' => [ 'all' => '#microsoft.graph.deviceEnrollmentConfiguration', ], + 'deviceEnrollmentLimitConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ], + 'deviceEnrollmentPlatformRestrictionsConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + ], + 'deviceEnrollmentNotificationConfiguration' => [ + 'all' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + ], 'windowsAutopilotDeploymentProfile' => [ 'windows' => '#microsoft.graph.windowsAutopilotDeploymentProfile', ], diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 5abff69..205da5b 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -518,14 +518,29 @@ 'assignments_delete_path' => '/deviceManagement/deviceHealthScripts/{id}/assignments/{assignmentId}', 'assignments_delete_method' => 'DELETE', ], - 'enrollmentRestriction' => [ + 'deviceEnrollmentLimitConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentLimitConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], + 'deviceEnrollmentPlatformRestrictionsConfiguration' => [ 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], 'allowed_expand' => [], 'type_family' => [ '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', - '#microsoft.graph.deviceEnrollmentLimitConfiguration', ], 'create_method' => 'POST', 'update_method' => 'PATCH', @@ -536,6 +551,39 @@ 'assignments_create_method' => 'POST', 'assignments_payload_key' => 'enrollmentConfigurationAssignments', ], + 'deviceEnrollmentNotificationConfiguration' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [ + '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + ], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'update_strip_keys' => [ + 'notificationTemplateSnapshots', + ], + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], + 'enrollmentRestriction' => [ + 'resource' => 'deviceManagement/deviceEnrollmentConfigurations', + 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version'], + 'allowed_expand' => [], + 'type_family' => [], + 'create_method' => 'POST', + 'update_method' => 'PATCH', + 'id_field' => 'id', + 'hydration' => 'properties', + 'assignments_list_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assignments', + 'assignments_create_path' => '/deviceManagement/deviceEnrollmentConfigurations/{id}/assign', + 'assignments_create_method' => 'POST', + 'assignments_payload_key' => 'enrollmentConfigurationAssignments', + ], 'termsAndConditions' => [ 'resource' => 'deviceManagement/termsAndConditions', 'allowed_select' => [ diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 85cddb9..d59d3cf 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -185,6 +185,37 @@ 'restore' => 'enabled', 'risk' => 'medium', ], + [ + 'type' => 'deviceEnrollmentLimitConfiguration', + 'label' => 'Enrollment Limits', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'label' => 'Platform Restrictions (Enrollment)', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], + [ + 'type' => 'deviceEnrollmentNotificationConfiguration', + 'label' => 'Enrollment Notifications', + 'category' => 'Enrollment', + 'platform' => 'all', + 'endpoint' => 'deviceManagement/deviceEnrollmentConfigurations', + 'filter' => "deviceEnrollmentConfigurationType eq 'EnrollmentNotificationsConfiguration'", + 'backup' => 'full', + 'restore' => 'preview-only', + 'risk' => 'high', + ], [ 'type' => 'enrollmentRestriction', 'label' => 'Enrollment Restrictions', diff --git a/specs/027-enrollment-config-subtypes/tasks.md b/specs/027-enrollment-config-subtypes/tasks.md index 46d669c..7342257 100644 --- a/specs/027-enrollment-config-subtypes/tasks.md +++ b/specs/027-enrollment-config-subtypes/tasks.md @@ -8,21 +8,21 @@ ## Phase 1: Setup - [x] T001 Create spec/plan/tasks and checklist. ## Phase 2: Research & Design -- [ ] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. -- [ ] T003 Decide restore modes and risk levels. +- [x] T002 Confirm `@odata.type` for each subtype and whether Graph supports assignments. +- [x] T003 Decide restore modes and risk levels. ## Phase 3: Tests (TDD) -- [ ] T004 Add sync tests ensuring each subtype is classified correctly. -- [ ] T005 Add snapshot capture test for at least one subtype. -- [ ] T006 Add restore preview test ensuring preview-only behavior. +- [x] T004 Add sync tests ensuring each subtype is classified correctly. +- [x] T005 Add snapshot capture test for at least one subtype. +- [x] T006 Add restore preview test ensuring preview-only behavior. ## Phase 4: Implementation -- [ ] T007 Add new types to `config/tenantpilot.php`. -- [ ] T008 Add contracts in `config/graph_contracts.php` (resource + type families). -- [ ] T009 Update `PolicySyncService` enrollment classification logic. -- [ ] T010 Add normalizer for readable UI output (key fields per subtype). +- [x] T007 Add new types to `config/tenantpilot.php`. +- [x] T008 Add contracts in `config/graph_contracts.php` (resource + type families). +- [x] T009 Update `PolicySyncService` enrollment classification logic. +- [x] T010 Add normalizer for readable UI output (key fields per subtype). +- [x] T013 Hydrate notification templates for enrollment notifications. ## Phase 5: Verification -- [ ] T011 Run targeted tests. -- [ ] T012 Run Pint (`./vendor/bin/pint --dirty`). - +- [x] T011 Run targeted tests. +- [x] T012 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php index a35ada5..7ad9258 100644 --- a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -109,11 +109,11 @@ $response->assertSee('app-2'); }); -test('policy detail renders normalized settings for enrollment restrictions', function () { +test('policy detail renders normalized settings for platform restrictions (enrollment)', function () { $policy = Policy::create([ 'tenant_id' => $this->tenant->id, 'external_id' => 'enroll-restrict-1', - 'policy_type' => 'enrollmentRestriction', + 'policy_type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'display_name' => 'Restriction A', 'platform' => 'all', ]); @@ -143,9 +143,9 @@ $response->assertOk(); $response->assertSee('Settings'); - $response->assertSee('Enrollment restrictions'); - $response->assertSee('Personal device enrollment blocked'); + $response->assertSee('Platform restrictions (enrollment)'); + $response->assertSee('Platform: Personal device enrollment blocked'); $response->assertSee('Enabled'); - $response->assertSee('Blocked SKUs'); + $response->assertSee('Platform: Blocked SKUs'); $response->assertSee('sku-1'); }); diff --git a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php index 051fcb0..4bf6b6c 100644 --- a/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php +++ b/tests/Feature/Filament/EnrollmentRestrictionsPreviewOnlyTest.php @@ -109,3 +109,103 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->applyCalls)->toBe(0); }); + +test('enrollment limit 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-enrollment-limit', + 'name' => 'Tenant Enrollment Limit', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enrollment-limit-1', + 'policy_type' => 'deviceEnrollmentLimitConfiguration', + 'display_name' => 'Enrollment Limit', + 'platform' => 'all', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Enrollment Limit 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.deviceEnrollmentLimitConfiguration', + 'id' => $policy->external_id, + 'displayName' => $policy->display_name, + 'limit' => 5, + ], + ]); + + $service = app(RestoreService::class); + $preview = $service->preview($tenant, $backupSet, [$backupItem->id]); + + $previewItem = collect($preview)->first(fn (array $item) => ($item['policy_type'] ?? null) === 'deviceEnrollmentLimitConfiguration'); + + 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); +}); diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index 56af173..a7495c7 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -56,3 +56,95 @@ $response->assertSee('Enable feature'); $response->assertSee('Normalized diff'); }); + +test('policy version detail shows enrollment notification template settings', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-enrollment-notify', + 'name' => 'Tenant Enrollment Notify', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enroll-notify-1', + 'policy_type' => 'deviceEnrollmentNotificationConfiguration', + 'display_name' => 'Enrollment Notifications', + 'platform' => 'all', + ]); + + $version = 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' => CarbonImmutable::now(), + 'snapshot' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'displayName' => 'Enrollment Notifications', + 'priority' => 1, + 'version' => 1, + 'platformType' => 'windows', + 'notificationTemplates' => ['Email_email-template-1', 'Push_push-template-1'], + 'notificationTemplateSnapshots' => [ + [ + 'channel' => 'Email', + 'template_id' => 'email-template-1', + 'template' => [ + 'id' => 'email-template-1', + 'displayName' => 'Email Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ], + 'localized_notification_messages' => [ + [ + 'locale' => 'en-us', + 'subject' => 'Email Subject', + 'messageTemplate' => 'Email Body', + 'isDefault' => true, + ], + ], + ], + [ + 'channel' => 'Push', + 'template_id' => 'push-template-1', + 'template' => [ + 'id' => 'push-template-1', + 'displayName' => 'Push Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ], + 'localized_notification_messages' => [ + [ + 'locale' => 'en-us', + 'subject' => 'Push Subject', + 'messageTemplate' => 'Push Body', + 'isDefault' => true, + ], + ], + ], + ], + ], + ]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings'); + + $response->assertOk(); + $response->assertSee('Enrollment notifications'); + $response->assertSee('Notification templates'); + $response->assertSee('Email (en-us) Subject'); + $response->assertSee('Email Subject'); + $response->assertSee('Email (en-us) Message'); + $response->assertSee('Email Body'); + $response->assertSee('Push (en-us) Subject'); + $response->assertSee('Push Subject'); + $response->assertSee('Push (en-us) Message'); + $response->assertSee('Push Body'); +}); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 2d0d171..10c6ec8 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -172,7 +172,39 @@ public function request(string $method, string $path, array $options = []): Grap $response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); $response->assertOk(); + $response->assertSee('Actions'); $response->assertSee($firstKey); $response->assertSee('ok'); $response->assertSee('missing'); }); + +test('tenant list shows Open in Entra action', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + Tenant::create([ + 'tenant_id' => 'tenant-ui-list', + 'name' => 'UI Tenant List', + 'app_client_id' => 'client-123', + ]); + + $response = $this->get(route('filament.admin.resources.tenants.index')); + + $response->assertOk(); + $response->assertSee('Open in Entra'); +}); + +test('tenant can be deactivated from the tenant detail action menu', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-ui-deactivate', + 'name' => 'UI Tenant Deactivate', + ]); + + Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) + ->callAction('archive'); + + $this->assertSoftDeleted('tenants', ['id' => $tenant->id]); +}); diff --git a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php index d296e33..a21e3b3 100644 --- a/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php +++ b/tests/Feature/PolicySyncEnrollmentConfigurationTypeCollisionTest.php @@ -106,7 +106,11 @@ $mock->shouldReceive('listPolicies') ->andReturnUsing(function (string $policyType) use ($payload) { - if (in_array($policyType, ['enrollmentRestriction', 'windowsEnrollmentStatusPage'], true)) { + if (in_array($policyType, [ + 'enrollmentRestriction', + 'windowsEnrollmentStatusPage', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + ], true)) { return new GraphResponse(true, $payload); } @@ -122,6 +126,11 @@ 'platform' => 'all', 'filter' => null, ], + [ + 'type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'platform' => 'all', + 'filter' => null, + ], [ 'type' => 'enrollmentRestriction', 'platform' => 'all', @@ -142,6 +151,100 @@ ->pluck('external_id') ->all(); + $platformRestrictionIds = Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration') + ->orderBy('external_id') + ->pluck('external_id') + ->all(); + expect($espIds)->toMatchArray(['esp-1']); - expect($restrictionIds)->toMatchArray(['other-1', 'restriction-1']); + expect($platformRestrictionIds)->toMatchArray(['restriction-1']); + expect($restrictionIds)->toMatchArray(['other-1']); +}); + +test('policy sync classifies enrollment configuration subtypes separately', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-sync-enrollment-subtypes', + 'name' => 'Tenant Sync Enrollment Subtypes', + 'metadata' => [], + 'is_current' => true, + ]); + + $tenant->makeCurrent(); + + $this->mock(GraphClientInterface::class, function (MockInterface $mock) { + $limitPayload = [ + 'id' => 'limit-1', + 'displayName' => 'Enrollment Limit', + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentLimitConfiguration', + 'limit' => 5, + ]; + + $platformRestrictionsPayload = [ + 'id' => 'platform-1', + 'displayName' => 'Platform Restrictions', + '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration', + 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionsConfiguration', + ]; + + $notificationPayload = [ + 'id' => 'notify-1', + 'displayName' => 'Enrollment Notifications', + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'deviceEnrollmentConfigurationType' => 'EnrollmentNotificationsConfiguration', + ]; + + $unfilteredPayload = [ + $limitPayload, + $platformRestrictionsPayload, + $notificationPayload, + ]; + + $mock->shouldReceive('listPolicies') + ->andReturnUsing(function (string $policyType) use ($notificationPayload, $unfilteredPayload) { + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + return new GraphResponse(true, [$notificationPayload]); + } + + if (in_array($policyType, [ + 'enrollmentRestriction', + 'deviceEnrollmentLimitConfiguration', + 'deviceEnrollmentPlatformRestrictionsConfiguration', + 'windowsEnrollmentStatusPage', + ], true)) { + return new GraphResponse(true, $unfilteredPayload); + } + + return new GraphResponse(true, []); + }); + }); + + $service = app(PolicySyncService::class); + + $service->syncPolicies($tenant, [ + ['type' => 'deviceEnrollmentLimitConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'deviceEnrollmentPlatformRestrictionsConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'deviceEnrollmentNotificationConfiguration', 'platform' => 'all', 'filter' => null], + ['type' => 'enrollmentRestriction', 'platform' => 'all', 'filter' => null], + ]); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentLimitConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['limit-1']); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentPlatformRestrictionsConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['platform-1']); + + expect(Policy::query() + ->where('tenant_id', $tenant->id) + ->where('policy_type', 'deviceEnrollmentNotificationConfiguration') + ->pluck('external_id') + ->all())->toMatchArray(['notify-1']); }); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php index 2a1ebfb..2cc8455 100644 --- a/tests/Feature/VersionCaptureWithAssignmentsTest.php +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -98,6 +98,76 @@ expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); +it('captures enrollment limit configuration version with assignments from graph', function () { + $this->policy->forceFill([ + 'policy_type' => 'deviceEnrollmentLimitConfiguration', + 'platform' => 'all', + ])->save(); + + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + '@odata.type' => '#microsoft.graph.deviceEnrollmentLimitConfiguration', + 'id' => 'test-policy-id', + 'displayName' => 'Enrollment Limit', + 'limit' => 5, + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->withArgs(function (string $policyType): bool { + return $policyType === 'deviceEnrollmentLimitConfiguration'; + }) + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolveGroupIds') + ->once() + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); + + $this->mock(AssignmentFilterResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version)->not->toBeNull() + ->and($version->policy_type)->toBe('deviceEnrollmentLimitConfiguration') + ->and($version->snapshot['@odata.type'] ?? null)->toBe('#microsoft.graph.deviceEnrollmentLimitConfiguration') + ->and($version->snapshot['limit'] ?? null)->toBe(5) + ->and($version->assignments)->toHaveCount(1) + ->and($version->metadata['assignments_count'])->toBe(1); +}); + it('hydrates assignment filter names when filter data is stored at root', function () { $this->mock(PolicySnapshotService::class, function ($mock) { $mock->shouldReceive('fetch') diff --git a/tests/Unit/PolicyNormalizerTest.php b/tests/Unit/PolicyNormalizerTest.php index 9a7dccf..a4cba28 100644 --- a/tests/Unit/PolicyNormalizerTest.php +++ b/tests/Unit/PolicyNormalizerTest.php @@ -67,34 +67,30 @@ expect(collect($result['warnings'])->join(' '))->toContain('@odata.type mismatch'); }); -it('normalizes enrollment restrictions platform restriction payload', function () { +it('normalizes enrollment platform restriction payload', function () { $snapshot = [ '@odata.type' => '#microsoft.graph.deviceEnrollmentPlatformRestrictionConfiguration', 'deviceEnrollmentConfigurationType' => 'deviceEnrollmentPlatformRestrictionConfiguration', 'displayName' => 'DeviceTypeRestriction', 'version' => 2, - // Graph uses this singular shape for platform restriction configs. 'platformRestriction' => [ 'platformBlocked' => false, 'personalDeviceEnrollmentBlocked' => true, ], ]; - $result = $this->normalizer->normalize($snapshot, 'enrollmentRestriction', 'all'); + $result = $this->normalizer->normalize($snapshot, 'deviceEnrollmentPlatformRestrictionsConfiguration', 'all'); - $block = collect($result['settings'])->firstWhere('title', 'Enrollment restrictions'); + $block = collect($result['settings'])->firstWhere('title', 'Platform restrictions (enrollment)'); expect($block)->not->toBeNull(); - $platformEntry = collect($block['entries'] ?? [])->firstWhere('key', 'Platform restrictions'); - expect($platformEntry)->toBeNull(); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Platform blocked')['value'] ?? null)->toBe('Disabled'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform blocked')['value'] ?? null)->toBe('Disabled'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Personal device enrollment blocked')['value'] ?? null)->toBe('Enabled'); - - expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS minimum version')['value'] ?? null)->toBe('None'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'OS maximum version')['value'] ?? null)->toBe('None'); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked manufacturers')['value'] ?? null)->toBe(['None']); - expect(collect($block['entries'] ?? [])->firstWhere('key', 'Blocked SKUs')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS minimum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: OS maximum version')['value'] ?? null)->toBe('None'); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked manufacturers')['value'] ?? null)->toBe(['None']); + expect(collect($block['entries'] ?? [])->firstWhere('key', 'Platform: Blocked SKUs')['value'] ?? null)->toBe(['None']); }); it('normalizes Autopilot deployment profile key fields', function () { diff --git a/tests/Unit/PolicySnapshotServiceTest.php b/tests/Unit/PolicySnapshotServiceTest.php index 9256adf..e3100ad 100644 --- a/tests/Unit/PolicySnapshotServiceTest.php +++ b/tests/Unit/PolicySnapshotServiceTest.php @@ -172,6 +172,120 @@ public function request(string $method, string $path, array $options = []): Grap } } +class EnrollmentNotificationSnapshotGraphClient implements GraphClientInterface +{ + public array $requests = []; + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + $this->requests[] = ['getPolicy', $policyType, $policyId, $options]; + + if ($policyType === 'deviceEnrollmentNotificationConfiguration') { + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Enrollment Notifications', + '@odata.type' => '#microsoft.graph.deviceEnrollmentNotificationConfiguration', + 'priority' => 1, + 'version' => 1, + 'platformType' => 'windows', + 'brandingOptions' => 'none', + 'templateType' => '0', + 'notificationMessageTemplateId' => '00000000-0000-0000-0000-000000000000', + 'notificationTemplates' => [ + 'Email_email-template-1', + 'Push_push-template-1', + ], + 'deviceEnrollmentConfigurationType' => 'enrollmentNotificationsConfiguration', + ], + ]); + } + + return new GraphResponse(success: true, data: [ + 'payload' => [ + 'id' => $policyId, + 'displayName' => 'Policy', + '@odata.type' => '#microsoft.graph.deviceConfiguration', + ], + ]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(success: true, data: []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requests[] = [$method, $path, $options]; + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1/localizedNotificationMessages')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'email-template-1_en-us', + 'locale' => 'en-us', + 'subject' => 'Email Subject', + 'messageTemplate' => 'Email Body', + 'isDefault' => true, + ], + ], + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1/localizedNotificationMessages')) { + return new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => 'push-template-1_en-us', + 'locale' => 'en-us', + 'subject' => 'Push Subject', + 'messageTemplate' => 'Push Body', + 'isDefault' => true, + ], + ], + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/email-template-1')) { + return new GraphResponse(success: true, data: [ + '@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity', + 'id' => 'email-template-1', + 'displayName' => 'Email Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ]); + } + + if ($method === 'GET' && str_contains($path, 'deviceManagement/notificationMessageTemplates/push-template-1')) { + return new GraphResponse(success: true, data: [ + '@odata.context' => 'https://graph.microsoft.com/beta/$metadata#deviceManagement/notificationMessageTemplates/$entity', + 'id' => 'push-template-1', + 'displayName' => 'Push Template', + 'defaultLocale' => 'en-us', + 'brandingOptions' => 'none', + ]); + } + + return new GraphResponse(success: true, data: []); + } +} + it('hydrates compliance policy scheduled actions into snapshots', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); @@ -247,6 +361,47 @@ public function request(string $method, string $path, array $options = []): Grap 'securityBaselinePolicy', ]); +it('hydrates enrollment notification templates into snapshots', function () { + $client = new EnrollmentNotificationSnapshotGraphClient; + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-enrollment-notifications', + 'app_client_id' => 'client-123', + 'app_client_secret' => 'secret-123', + 'is_current' => true, + ]); + $tenant->makeCurrent(); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'enroll-notify-123', + 'policy_type' => 'deviceEnrollmentNotificationConfiguration', + 'display_name' => 'Enrollment Notifications', + 'platform' => 'all', + ]); + + $service = app(PolicySnapshotService::class); + $result = $service->fetch($tenant, $policy); + + expect($result)->toHaveKey('payload'); + expect($result['payload'])->toHaveKey('notificationTemplateSnapshots'); + expect($result['payload']['notificationTemplateSnapshots'])->toHaveCount(2); + expect($result['metadata']['enrollment_notification_templates_hydration'] ?? null)->toBe('complete'); + + $email = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Email'); + expect($email)->not->toBeNull() + ->and($email['template_id'] ?? null)->toBe('email-template-1') + ->and($email['template']['displayName'] ?? null)->toBe('Email Template') + ->and($email['localized_notification_messages'][0]['subject'] ?? null)->toBe('Email Subject'); + + $push = collect($result['payload']['notificationTemplateSnapshots'])->firstWhere('channel', 'Push'); + expect($push)->not->toBeNull() + ->and($push['template_id'] ?? null)->toBe('push-template-1') + ->and($push['template']['displayName'] ?? null)->toBe('Push Template') + ->and($push['localized_notification_messages'][0]['subject'] ?? null)->toBe('Push Subject'); +}); + it('filters mobile app snapshots to metadata-only keys', function () { $client = new PolicySnapshotGraphClient; app()->instance(GraphClientInterface::class, $client); From 2ca989c00f4047429303f69a7ef5476a219b0caf Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 21:28:08 +0000 Subject: [PATCH 15/18] feat/031-tenant-portfolio-context-switch (#32) Tenant Switch implemented Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/32 --- app/Filament/Pages/Tenancy/RegisterTenant.php | 83 +++++++ .../BackupItemsRelationManager.php | 12 +- .../Resources/PolicyVersionResource.php | 4 +- app/Filament/Resources/TenantResource.php | 223 +++++++++++++++++- .../TenantResource/Pages/CreateTenant.php | 15 ++ app/Jobs/BulkTenantSyncJob.php | 152 ++++++++++++ app/Models/Tenant.php | 25 +- app/Models/User.php | 119 +++++++++- app/Models/UserTenantPreference.php | 26 ++ app/Providers/AppServiceProvider.php | 37 ++- app/Providers/Filament/AdminPanelProvider.php | 6 + app/Support/TenantRole.php | 21 ++ database/factories/TenantFactory.php | 1 + ...35956_add_environment_to_tenants_table.php | 24 ++ ..._01_04_135957_create_tenant_user_table.php | 61 +++++ ...7_create_user_tenant_preferences_table.php | 28 +++ .../views/admin-consent-callback.blade.php | 6 +- .../checklists/requirements.md | 13 + .../plan.md | 34 +++ .../spec.md | 89 +++++++ .../tasks.md | 33 +++ tests/Feature/BulkDeleteBackupSetsTest.php | 19 +- tests/Feature/BulkDeleteMixedStatusTest.php | 7 +- tests/Feature/BulkDeleteRestoreRunsTest.php | 7 +- .../Feature/BulkForceDeleteBackupSetsTest.php | 7 +- .../BulkForceDeletePolicyVersionsTest.php | 6 + .../BulkForceDeleteRestoreRunsTest.php | 7 +- tests/Feature/BulkPruneSkipReasonsTest.php | 8 +- tests/Feature/BulkPruneVersionsTest.php | 8 +- tests/Feature/BulkRestoreBackupSetsTest.php | 7 +- .../Feature/BulkRestorePolicyVersionsTest.php | 6 + tests/Feature/BulkRestoreRestoreRunsTest.php | 7 +- tests/Feature/BulkTypeToConfirmTest.php | 19 +- ...AppProtectionPolicySettingsDisplayTest.php | 5 +- ...EnrollmentAutopilotSettingsDisplayTest.php | 9 +- .../GroupPolicyConfigurationHydrationTest.php | 5 +- tests/Feature/Filament/HousekeepingTest.php | 56 +++++ .../Filament/MalformedSnapshotWarningTest.php | 7 +- .../Filament/ODataTypeMismatchTest.php | 5 +- .../PolicyCaptureSnapshotOptionsTest.php | 5 + tests/Feature/Filament/PolicyListingTest.php | 20 +- .../Filament/PolicySettingsDisplayTest.php | 5 +- ...olicySettingsStandardRendersArraysTest.php | 5 +- .../PolicyVersionReadableLayoutTest.php | 5 +- .../PolicyVersionRestoreViaWizardTest.php | 11 +- .../PolicyVersionScopeTagsDisplayTest.php | 5 +- .../Filament/PolicyVersionSettingsTest.php | 10 +- tests/Feature/Filament/PolicyVersionTest.php | 5 +- .../PolicyViewSettingsCatalogReadableTest.php | 40 +++- .../Filament/RestoreItemSelectionTest.php | 5 + .../ScriptPoliciesNormalizedDisplayTest.php | 29 ++- .../SettingsCatalogPolicyHydrationTest.php | 10 +- ...ingsCatalogPolicyNormalizedDisplayTest.php | 7 +- .../SettingsCatalogPolicySyncTest.php | 5 +- ...gsCatalogRestoreApplySettingsPatchTest.php | 5 +- .../Filament/SettingsCatalogRestoreTest.php | 5 +- ...SettingsCatalogSettingsTableRenderTest.php | 7 +- .../Filament/TenantMakeCurrentTest.php | 6 + .../TenantPortfolioContextSwitchTest.php | 104 ++++++++ .../Feature/Filament/TenantRbacWizardTest.php | 33 +++ tests/Feature/Filament/TenantSetupTest.php | 36 ++- .../Filament/WindowsUpdateRingPolicyTest.php | 5 +- .../PolicyVersionViewAssignmentsTest.php | 39 ++- tests/Feature/RestoreGroupMappingTest.php | 11 + .../Feature/RestorePreviewDiffWizardTest.php | 6 + tests/Feature/RestoreRiskChecksWizardTest.php | 16 ++ tests/Feature/RestoreRunArchiveGuardTest.php | 6 + tests/Feature/RestoreRunRerunTest.php | 7 +- tests/Feature/RestoreRunWizardExecuteTest.php | 11 + .../Feature/RestoreRunWizardMetadataTest.php | 6 + tests/Pest.php | 25 ++ tests/Unit/BulkActionPermissionTest.php | 7 +- 72 files changed, 1608 insertions(+), 101 deletions(-) create mode 100644 app/Filament/Pages/Tenancy/RegisterTenant.php create mode 100644 app/Jobs/BulkTenantSyncJob.php create mode 100644 app/Models/UserTenantPreference.php create mode 100644 app/Support/TenantRole.php create mode 100644 database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php create mode 100644 database/migrations/2026_01_04_135957_create_tenant_user_table.php create mode 100644 database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php create mode 100644 specs/031-tenant-portfolio-context-switch/checklists/requirements.md create mode 100644 specs/031-tenant-portfolio-context-switch/plan.md create mode 100644 specs/031-tenant-portfolio-context-switch/spec.md create mode 100644 specs/031-tenant-portfolio-context-switch/tasks.md create mode 100644 tests/Feature/Filament/TenantPortfolioContextSwitchTest.php diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php new file mode 100644 index 0000000..b39b512 --- /dev/null +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -0,0 +1,83 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + Forms\Components\Select::make('environment') + ->options([ + 'prod' => 'PROD', + 'dev' => 'DEV', + 'staging' => 'STAGING', + 'other' => 'Other', + ]) + ->default('other') + ->required(), + Forms\Components\TextInput::make('tenant_id') + ->label('Tenant ID (GUID)') + ->required() + ->maxLength(255) + ->unique(ignoreRecord: true), + Forms\Components\TextInput::make('domain') + ->label('Primary domain') + ->maxLength(255), + Forms\Components\TextInput::make('app_client_id') + ->label('App Client ID') + ->maxLength(255), + Forms\Components\TextInput::make('app_client_secret') + ->label('App Client Secret') + ->password() + ->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null) + ->dehydrated(fn ($state) => filled($state)), + Forms\Components\TextInput::make('app_certificate_thumbprint') + ->label('Certificate thumbprint') + ->maxLength(255), + Forms\Components\Textarea::make('app_notes') + ->label('Notes') + ->rows(3), + ]); + } + + /** + * @param array $data + */ + protected function handleRegistration(array $data): Model + { + $tenant = Tenant::create($data); + + $user = auth()->user(); + + if ($user instanceof User) { + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => TenantRole::Owner->value], + ]); + } + + return $tenant; + } +} diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 914991f..e5e8035 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -112,8 +112,16 @@ public function table(Table $table): Table Actions\ActionGroup::make([ Actions\ViewAction::make() ->label('View policy') - ->url(fn ($record) => $record->policy_id ? PolicyResource::getUrl('view', ['record' => $record->policy_id]) : null) - ->hidden(fn ($record) => ! $record->policy_id) + ->url(function (BackupItem $record): ?string { + if (! $record->policy_id) { + return null; + } + + $tenant = $this->getOwnerRecord()->tenant ?? \App\Models\Tenant::current(); + + return PolicyResource::getUrl('view', ['record' => $record->policy_id], tenant: $tenant); + }) + ->hidden(fn (BackupItem $record) => ! $record->policy_id) ->openUrlInNewTab(true), Actions\Action::make('remove') ->label('Remove') diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index b9dd732..9d6259a 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -186,9 +186,7 @@ public static function table(Table $table): Table ->falseLabel('Archived'), ]) ->actions([ - Actions\ViewAction::make() - ->url(fn (PolicyVersion $record) => static::getUrl('view', ['record' => $record])) - ->openUrlInNewTab(false), + Actions\ViewAction::make(), Actions\ActionGroup::make([ Actions\Action::make('restore_via_wizard') ->label('Restore via Wizard') diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 92f5156..f32bd90 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -4,13 +4,18 @@ use App\Filament\Resources\TenantResource\Pages; use App\Http\Controllers\RbacDelegatedAuthController; +use App\Jobs\BulkTenantSyncJob; +use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; +use App\Models\User; +use App\Services\BulkOperationService; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacHealthService; use App\Services\Intune\RbacOnboardingService; use App\Services\Intune\TenantConfigService; use App\Services\Intune\TenantPermissionService; +use App\Support\TenantRole; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -23,6 +28,8 @@ use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -33,6 +40,8 @@ class TenantResource extends Resource { protected static ?string $model = Tenant::class; + protected static bool $isScopedToTenant = false; + protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2'; protected static string|UnitEnum|null $navigationGroup = 'Settings'; @@ -44,6 +53,15 @@ public static function form(Schema $schema): Schema Forms\Components\TextInput::make('name') ->required() ->maxLength(255), + Forms\Components\Select::make('environment') + ->options([ + 'prod' => 'PROD', + 'dev' => 'DEV', + 'staging' => 'STAGING', + 'other' => 'Other', + ]) + ->default('other') + ->required(), Forms\Components\TextInput::make('tenant_id') ->label('Tenant ID (GUID)') ->required() @@ -69,10 +87,28 @@ public static function form(Schema $schema): Schema ]); } + public static function getEloquentQuery(): Builder + { + $user = auth()->user(); + + if (! $user instanceof User) { + return parent::getEloquentQuery()->whereRaw('1 = 0'); + } + + $tenantIds = $user->tenants() + ->withTrashed() + ->pluck('tenants.id'); + + return parent::getEloquentQuery() + ->withTrashed() + ->whereIn('id', $tenantIds) + ->withCount('policies') + ->withMax('policies as last_policy_sync_at', 'last_synced_at'); + } + public static function table(Table $table): Table { return $table - ->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed()) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable(), @@ -80,6 +116,23 @@ public static function table(Table $table): Table ->label('Tenant ID') ->copyable() ->searchable(), + Tables\Columns\TextColumn::make('environment') + ->badge() + ->color(fn (?string $state) => match ($state) { + 'prod' => 'danger', + 'dev' => 'warning', + 'staging' => 'info', + default => 'gray', + }) + ->sortable(), + Tables\Columns\TextColumn::make('policies_count') + ->label('Policies') + ->numeric() + ->sortable(), + Tables\Columns\TextColumn::make('last_policy_sync_at') + ->label('Last Sync') + ->since() + ->sortable(), Tables\Columns\TextColumn::make('domain') ->copyable() ->toggleable(), @@ -102,6 +155,13 @@ public static function table(Table $table): Table ->trueLabel('All') ->falseLabel('Archived') ->default(true), + Tables\Filters\SelectFilter::make('environment') + ->options([ + 'prod' => 'PROD', + 'dev' => 'DEV', + 'staging' => 'STAGING', + 'other' => 'Other', + ]), Tables\Filters\SelectFilter::make('app_status') ->options([ 'ok' => 'OK', @@ -113,6 +173,51 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), ActionGroup::make([ + Actions\Action::make('syncTenant') + ->label('Sync') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->visible(function (Tenant $record): bool { + if (! $record->isActive()) { + return false; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->canSyncTenant($record); + }) + ->action(function (Tenant $record, AuditLogger $auditLogger): void { + SyncPoliciesJob::dispatch($record->getKey()); + + $auditLogger->log( + tenant: $record, + action: 'tenant.sync_dispatched', + resourceType: 'tenant', + resourceId: (string) $record->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $record->tenant_id]], + ); + + Notification::make() + ->title('Sync started') + ->body("Sync dispatched for {$record->name}.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->success() + ->sendToDatabase(auth()->user()) + ->send(); + }), + Actions\Action::make('openTenant') + ->label('Open') + ->icon('heroicon-o-arrow-right') + ->color('primary') + ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) + ->visible(fn (Tenant $record) => $record->isActive()), Actions\EditAction::make(), Actions\RestoreAction::make() ->label('Restore') @@ -242,7 +347,106 @@ public static function table(Table $table): Table }), ])->icon('heroicon-o-ellipsis-vertical'), ]) - ->bulkActions([]) + ->bulkActions([ + Actions\BulkAction::make('syncSelected') + ->label('Sync selected') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->requiresConfirmation() + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->tenants() + ->whereIn('role', [ + TenantRole::Owner->value, + TenantRole::Manager->value, + TenantRole::Operator->value, + ]) + ->exists(); + }) + ->authorize(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return $user->tenants() + ->whereIn('role', [ + TenantRole::Owner->value, + TenantRole::Manager->value, + TenantRole::Operator->value, + ]) + ->exists(); + }) + ->action(function (Collection $records, AuditLogger $auditLogger): void { + $user = auth()->user(); + + if (! $user instanceof User) { + return; + } + + $eligible = $records + ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) + ->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant)); + + if ($eligible->isEmpty()) { + Notification::make() + ->title('Bulk sync skipped') + ->body('No eligible tenants selected.') + ->icon('heroicon-o-information-circle') + ->info() + ->sendToDatabase($user) + ->send(); + + return; + } + + $tenantContext = Tenant::current() ?? $eligible->first(); + + if (! $tenantContext) { + return; + } + + $ids = $eligible->pluck('id')->toArray(); + $count = $eligible->count(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count); + + foreach ($eligible as $tenant) { + SyncPoliciesJob::dispatch($tenant->getKey()); + + $auditLogger->log( + tenant: $tenant, + action: 'tenant.sync_dispatched', + resourceType: 'tenant', + resourceId: (string) $tenant->id, + status: 'success', + context: ['metadata' => ['tenant_id' => $tenant->tenant_id]], + ); + } + + $count = $eligible->count(); + + Notification::make() + ->title('Bulk sync started') + ->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.") + ->icon('heroicon-o-arrow-path') + ->iconColor('warning') + ->success() + ->duration(8000) + ->sendToDatabase($user) + ->send(); + + BulkTenantSyncJob::dispatch($run->id); + }) + ->deselectRecordsAfterCompletion(), + ]) ->headerActions([]); } @@ -440,7 +644,10 @@ public static function rbacAction(): Actions\Action ->label('Open RBAC login') ->url(route('admin.rbac.start', [ 'tenant' => $record->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', $record), + 'return' => route('filament.admin.resources.tenants.view', [ + 'tenant' => $record->external_id, + 'record' => $record, + ]), ])), ]) ->warning() @@ -579,7 +786,10 @@ private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Acti ->label('Login to load roles') ->url(route('admin.rbac.start', [ 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', $tenant), + 'return' => route('filament.admin.resources.tenants.view', [ + 'tenant' => $tenant->external_id, + 'record' => $tenant, + ]), ])); } @@ -761,7 +971,10 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act ->label('Login to search groups') ->url(route('admin.rbac.start', [ 'tenant' => $tenant->graphTenantId(), - 'return' => route('filament.admin.resources.tenants.view', $tenant), + 'return' => route('filament.admin.resources.tenants.view', [ + 'tenant' => $tenant->external_id, + 'record' => $tenant, + ]), ])); } diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 2a0c9ed..6e592c6 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -3,9 +3,24 @@ namespace App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource; +use App\Models\User; +use App\Support\TenantRole; use Filament\Resources\Pages\CreateRecord; class CreateTenant extends CreateRecord { protected static string $resource = TenantResource::class; + + protected function afterCreate(): void + { + $user = auth()->user(); + + if (! $user instanceof User) { + return; + } + + $user->tenants()->syncWithoutDetaching([ + $this->record->getKey() => ['role' => TenantRole::Owner->value], + ]); + } } diff --git a/app/Jobs/BulkTenantSyncJob.php b/app/Jobs/BulkTenantSyncJob.php new file mode 100644 index 0000000..50fd31e --- /dev/null +++ b/app/Jobs/BulkTenantSyncJob.php @@ -0,0 +1,152 @@ +find($this->bulkRunId); + + if (! $run || $run->status !== 'pending') { + return; + } + + $service->start($run); + + try { + $chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10)); + $itemCount = 0; + + $supported = config('tenantpilot.supported_policy_types'); + + $totalItems = $run->total_items ?: count($run->item_ids ?? []); + $failureThreshold = (int) floor($totalItems / 2); + + foreach (($run->item_ids ?? []) as $tenantId) { + $itemCount++; + + try { + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant) { + $service->recordFailure($run, (string) $tenantId, 'Tenant not found'); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + + continue; + } + + if (! $tenant->isActive()) { + $service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active'); + + continue; + } + + if (! $run->user || ! $run->user->canSyncTenant($tenant)) { + $service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant'); + + continue; + } + + $syncService->syncPolicies($tenant, $supported); + + $service->recordSuccess($run); + } catch (Throwable $e) { + $service->recordFailure($run, (string) $tenantId, $e->getMessage()); + + if ($run->failed > $failureThreshold) { + $service->abort($run, 'Circuit breaker: more than 50% of items failed.'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Aborted') + ->body('Circuit breaker triggered: too many failures (>50%).') + ->icon('heroicon-o-exclamation-triangle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + return; + } + } + + if ($itemCount % $chunkSize === 0) { + $run->refresh(); + } + } + + $service->complete($run); + + if ($run->user) { + $message = "Synced {$run->succeeded} tenant(s)"; + + if ($run->skipped > 0) { + $message .= " ({$run->skipped} skipped)"; + } + + if ($run->failed > 0) { + $message .= " ({$run->failed} failed)"; + } + + $message .= '.'; + + Notification::make() + ->title('Bulk Sync Completed') + ->body($message) + ->icon('heroicon-o-check-circle') + ->success() + ->sendToDatabase($run->user) + ->send(); + } + } catch (Throwable $e) { + $service->fail($run, $e->getMessage()); + + $run->refresh(); + $run->load('user'); + + if ($run->user) { + Notification::make() + ->title('Bulk Sync Failed') + ->body($e->getMessage()) + ->icon('heroicon-o-x-circle') + ->danger() + ->sendToDatabase($run->user) + ->send(); + } + + throw $e; + } + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 3944280..29c5af5 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -2,16 +2,19 @@ namespace App\Models; +use Filament\Facades\Filament; +use Filament\Models\Contracts\HasName; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use RuntimeException; -class Tenant extends Model +class Tenant extends Model implements HasName { use HasFactory; use SoftDeletes; @@ -114,6 +117,12 @@ public function makeCurrent(): void public static function current(): self { + $filamentTenant = Filament::getTenant(); + + if ($filamentTenant instanceof self) { + return $filamentTenant; + } + $envTenantId = getenv('INTUNE_TENANT_ID') ?: null; if ($envTenantId) { @@ -142,6 +151,20 @@ public static function current(): self return $tenant; } + public function getFilamentName(): string + { + $environment = strtoupper((string) ($this->environment ?? 'other')); + + return "{$this->name} ({$environment})"; + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->withPivot('role') + ->withTimestamps(); + } + public function policies(): HasMany { return $this->hasMany(Policy::class); diff --git a/app/Models/User.php b/app/Models/User.php index ddf23da..8c08d23 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,13 +2,21 @@ namespace App\Models; +use App\Support\TenantRole; use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasDefaultTenant; +use Filament\Models\Contracts\HasTenants; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Schema; -class User extends Authenticatable implements FilamentUser +class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; @@ -51,4 +59,113 @@ public function canAccessPanel(Panel $panel): bool { return true; } + + public function tenants(): BelongsToMany + { + return $this->belongsToMany(Tenant::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function tenantPreferences(): HasMany + { + return $this->hasMany(UserTenantPreference::class); + } + + private function tenantPivotTableExists(): bool + { + static $exists; + + return $exists ??= Schema::hasTable('tenant_user'); + } + + private function tenantPreferencesTableExists(): bool + { + static $exists; + + return $exists ??= Schema::hasTable('user_tenant_preferences'); + } + + public function tenantRole(Tenant $tenant): ?TenantRole + { + if (! $this->tenantPivotTableExists()) { + return null; + } + + $role = $this->tenants() + ->whereKey($tenant->getKey()) + ->value('role'); + + if (! is_string($role)) { + return null; + } + + return TenantRole::tryFrom($role); + } + + public function canSyncTenant(Tenant $tenant): bool + { + $role = $this->tenantRole($tenant); + + return $role?->canSync() ?? false; + } + + public function canAccessTenant(Model $tenant): bool + { + if (! $tenant instanceof Tenant) { + return false; + } + + if (! $this->tenantPivotTableExists()) { + return false; + } + + return $this->tenants() + ->whereKey($tenant->getKey()) + ->exists(); + } + + public function getTenants(Panel $panel): array|Collection + { + if (! $this->tenantPivotTableExists()) { + return collect(); + } + + return $this->tenants() + ->where('status', 'active') + ->orderBy('name') + ->get(); + } + + public function getDefaultTenant(Panel $panel): ?Model + { + if (! $this->tenantPivotTableExists()) { + return null; + } + + $tenantId = null; + + if ($this->tenantPreferencesTableExists()) { + $tenantId = $this->tenantPreferences() + ->whereNotNull('last_used_at') + ->orderByDesc('last_used_at') + ->value('tenant_id'); + } + + if ($tenantId !== null) { + $tenant = $this->tenants() + ->where('status', 'active') + ->whereKey($tenantId) + ->first(); + + if ($tenant !== null) { + return $tenant; + } + } + + return $this->tenants() + ->where('status', 'active') + ->orderBy('name') + ->first(); + } } diff --git a/app/Models/UserTenantPreference.php b/app/Models/UserTenantPreference.php new file mode 100644 index 0000000..dab7b6c --- /dev/null +++ b/app/Models/UserTenantPreference.php @@ -0,0 +1,26 @@ + 'boolean', + 'last_used_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fbe5206..9da238b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Models\Tenant; +use App\Models\User; +use App\Models\UserTenantPreference; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; @@ -18,6 +21,9 @@ use App\Services\Intune\WindowsFeatureUpdateProfileNormalizer; use App\Services\Intune\WindowsQualityUpdateProfileNormalizer; use App\Services\Intune\WindowsUpdateRingNormalizer; +use Filament\Events\TenantSet; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -66,6 +72,35 @@ public function register(): void */ public function boot(): void { - // + Event::listen(TenantSet::class, function (TenantSet $event): void { + static $hasPreferencesTable; + + $hasPreferencesTable ??= Schema::hasTable('user_tenant_preferences'); + + if (! $hasPreferencesTable) { + return; + } + + $tenant = $event->getTenant(); + $user = $event->getUser(); + + if (! $tenant instanceof Tenant) { + return; + } + + if (! $user instanceof User) { + return; + } + + UserTenantPreference::query()->updateOrCreate( + [ + 'user_id' => $user->getKey(), + 'tenant_id' => $tenant->getKey(), + ], + [ + 'last_used_at' => now(), + ], + ); + }); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index 9827abc..351d2bd 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -2,6 +2,8 @@ namespace App\Providers\Filament; +use App\Filament\Pages\Tenancy\RegisterTenant; +use App\Models\Tenant; use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\DisableBladeIconComponents; @@ -29,6 +31,10 @@ public function panel(Panel $panel): Panel ->id('admin') ->path('admin') ->login() + ->tenant(Tenant::class, slugAttribute: 'external_id') + ->tenantRoutePrefix('t') + ->searchableTenantMenu() + ->tenantRegistration(RegisterTenant::class) ->colors([ 'primary' => Color::Amber, ]) diff --git a/app/Support/TenantRole.php b/app/Support/TenantRole.php new file mode 100644 index 0000000..db82a29 --- /dev/null +++ b/app/Support/TenantRole.php @@ -0,0 +1,21 @@ + true, + self::Readonly => false, + }; + } +} diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php index 0938ebe..3abfbdd 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -26,6 +26,7 @@ public function definition(): array 'app_status' => 'ok', 'app_notes' => null, 'status' => 'active', + 'environment' => 'other', 'is_current' => false, 'metadata' => [], ]; diff --git a/database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php b/database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php new file mode 100644 index 0000000..8576c8c --- /dev/null +++ b/database/migrations/2026_01_04_135956_add_environment_to_tenants_table.php @@ -0,0 +1,24 @@ +string('environment')->default('other')->after('status'); + $table->index('environment'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropIndex(['environment']); + $table->dropColumn('environment'); + }); + } +}; diff --git a/database/migrations/2026_01_04_135957_create_tenant_user_table.php b/database/migrations/2026_01_04_135957_create_tenant_user_table.php new file mode 100644 index 0000000..c0b5dcf --- /dev/null +++ b/database/migrations/2026_01_04_135957_create_tenant_user_table.php @@ -0,0 +1,61 @@ +foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('role')->default('owner'); + $table->timestamps(); + + $table->unique(['tenant_id', 'user_id']); + }); + + $now = now(); + + $tenantIds = DB::table('tenants') + ->whereNull('deleted_at') + ->pluck('id'); + + $userIds = DB::table('users')->pluck('id'); + + if ($tenantIds->isEmpty() || $userIds->isEmpty()) { + return; + } + + $rows = []; + + foreach ($tenantIds as $tenantId) { + foreach ($userIds as $userId) { + $rows[] = [ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'role' => 'owner', + 'created_at' => $now, + 'updated_at' => $now, + ]; + + if (count($rows) >= 500) { + DB::table('tenant_user')->insertOrIgnore($rows); + $rows = []; + } + } + } + + if ($rows !== []) { + DB::table('tenant_user')->insertOrIgnore($rows); + } + } + + public function down(): void + { + Schema::dropIfExists('tenant_user'); + } +}; diff --git a/database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php b/database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php new file mode 100644 index 0000000..e460b08 --- /dev/null +++ b/database/migrations/2026_01_04_135957_create_user_tenant_preferences_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->boolean('is_favorite')->default(false); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'tenant_id']); + $table->index(['user_id', 'last_used_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_tenant_preferences'); + } +}; diff --git a/resources/views/admin-consent-callback.blade.php b/resources/views/admin-consent-callback.blade.php index 9e7cb3f..3cfe8df 100644 --- a/resources/views/admin-consent-callback.blade.php +++ b/resources/views/admin-consent-callback.blade.php @@ -31,7 +31,11 @@

Admin consent wurde bestätigt.

@endif -

Zurück zur Tenant-Detailseite

+

+ + Zurück zur Tenant-Detailseite + +

diff --git a/specs/031-tenant-portfolio-context-switch/checklists/requirements.md b/specs/031-tenant-portfolio-context-switch/checklists/requirements.md new file mode 100644 index 0000000..7834e92 --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (031) + +**Created**: 2026-01-04 +**Feature**: [spec.md](../spec.md) + +- [x] Tenant memberships/roles exist and are enforced. +- [x] Current Tenant context is per-user and always visible. +- [x] Portfolio shows only accessible tenants with environment + health/status. +- [x] “Open tenant” changes context and redirects into tenant-scoped area. +- [x] Tenant-scoped resources are filtered by context and deny unauthorized access. +- [x] Bulk “Sync selected” dispatches per-tenant jobs and is role-gated. +- [x] Restore flows show target tenant + environment and require tenant-aware confirmation. +- [x] Pest tests cover authorization + context switching + bulk actions. diff --git a/specs/031-tenant-portfolio-context-switch/plan.md b/specs/031-tenant-portfolio-context-switch/plan.md new file mode 100644 index 0000000..f32db6c --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/plan.md @@ -0,0 +1,34 @@ +# Plan: Tenant Portfolio & Context Switch (031) + +**Branch**: `feat/031-tenant-portfolio-context-switch` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md) + +## Approach +1. Decide on the tenant context mechanism: + - Preferred: Filament tenancy (tenant in URL) + built-in tenant switcher. + - Fallback: session-based Current Tenant + visible banner (avoid global/DB state as “source of truth”). +2. Add data model pieces: + - tenant membership/role mapping + - tenant environment attribute + - optional user preferences (favorites + last used) +3. Implement a single TenantContext resolver (HTTP + console) and central authorization gate/policy: + - deny-by-default if no access + - keep `INTUNE_TENANT_ID` as console override for automation +4. Update tenant-scoped resources/services to use TenantContext instead of `Tenant::current()` and ensure base queries are tenant-scoped. +5. Extend `TenantResource` into a portfolio view: + - access-scoped query + - environment/health columns + - “Open” action + “Sync” action + - bulk “Sync selected” +6. Add restore guardrails: + - target tenant badge/header on restore pages + - type-to-confirm includes tenant/environment (e.g. `RESTORE PROD`) +7. Add targeted Pest tests for authorization, context switching, and bulk sync. +8. Run Pint + targeted tests; document rollout/migration notes. + +## Decisions / Notes +- Avoid a global `tenants.is_current` UI context (unsafe for MSP); prefer per-user context. +- Avoid storing Current Tenant in the `users` table as the source of truth (cross-tab risk); prefer route/session context, optionally persisting “last used” separately. +- Start with user-based tenant memberships; extend to organization/group principals later if needed. +- Prefer deriving portfolio stats via relationships (`withCount`, `withMax`) initially; add denormalized summary columns only if needed for performance. diff --git a/specs/031-tenant-portfolio-context-switch/spec.md b/specs/031-tenant-portfolio-context-switch/spec.md new file mode 100644 index 0000000..a538dad --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/spec.md @@ -0,0 +1,89 @@ +# Feature Specification: Tenant Portfolio & Context Switch (031) + +**Feature Branch**: `feat/031-tenant-portfolio-context-switch` +**Created**: 2026-01-04 +**Status**: Implemented (ready to merge) +**Risk**: Medium +**Priority**: P1 + +## Context +Today TenantPilot behaves like a single-tenant app: +- The “current tenant” is global (`tenants.is_current` + `Tenant::current()`), not per user. +- Most tenant-scoped screens implicitly use `Tenant::current()`. + +This is limiting and potentially unsafe for: +- Customers running multiple tenants (PROD/DEV/STAGING). +- MSPs managing many customer tenants. + +We need a tenant-agnostic **Portfolio** view plus an explicit, always-visible **Current Tenant** context for all tenant-scoped areas (Policies, Backups, Restore Runs, etc.). + +## Design Considerations (Best Practice) +- Prefer an explicit tenant context (route parameter or session) over hidden global state. +- Avoid storing Current Tenant in the `users` table as the source of truth (cross-tab risk). If persistence is needed, store **“last used”** separately and treat it as a default for new sessions. +- Keep console/automation behavior stable: `INTUNE_TENANT_ID` can remain a console override, but tenant-scoped UI must not depend on it. + +## User Scenarios & Testing + +### User Story 1 — Portfolio overview (P1) +As a user with access to multiple tenants, I can see a portfolio overview with health/status and key counts. + +**Acceptance Scenarios** +1. Tenants list shows only tenants the user can access. +2. Portfolio shows environment badge (PROD/DEV/STAGING/OTHER) and connection/health indicators. +3. Portfolio columns can be filtered by environment and connection status. + +### User Story 2 — Safe tenant context switching (P1) +As a user, I can switch the Current Tenant via a topbar switcher or by clicking “Open” in the portfolio, and all tenant-scoped screens reflect that tenant. + +**Acceptance Scenarios** +1. Switching tenant updates the visible Current Tenant badge and redirects to a default tenant-scoped landing page (e.g. Policies). +2. Policies, Backups, Restore Runs, and Policy Versions are scoped to the selected tenant. +3. Restore flows always show the target tenant and environment prominently and require tenant-aware type-to-confirm. + +### User Story 3 — Multi-tenant bulk actions (P2) +As an operator, I can select multiple tenants in the portfolio and run safe bulk actions (initially Sync). + +**Acceptance Scenarios** +1. Bulk “Sync selected” dispatches a sync job per tenant (batch) and shows progress. +2. Readonly users cannot trigger bulk sync. + +### User Story 4 — Authorization hardening (P1) +As a user, I cannot access tenants or tenant-scoped data I am not authorized for. + +**Acceptance Scenarios** +1. Attempting to open a tenant without access is denied (403) and does not change Current Tenant. +2. Direct URL access to tenant-scoped pages for an unauthorized tenant returns 403/404. + +## Requirements + +### Functional Requirements +- **FR-001**: Introduce a per-user Current Tenant context for all tenant-scoped screens. +- **FR-002**: Current Tenant context must be always visible in the UI (topbar) to reduce “wrong tenant” operations. +- **FR-003**: Add an “Open” action from the portfolio to set Current Tenant and redirect into the tenant-scoped area. +- **FR-004**: Portfolio view is tenant-agnostic and supports filtering, search, and safe bulk actions. +- **FR-005**: Tenant access is enforced centrally (single `canAccessTenant(...)` gate/policy used by UI + routes + services). +- **FR-006**: Restore remains single-tenant; restore actions must include explicit tenant/environment confirmations and never rely on hidden global context. +- **FR-007**: Bulk Sync is tenant-safe: per-tenant authorization, per-tenant job execution, and audit logs for each tenant sync trigger. + +### UX / UI Requirements +- **UX-001**: Topbar shows “Tenant: ” with an environment badge (PROD/DEV/STAGING/OTHER) and is accessible from all tenant-scoped pages. +- **UX-002**: Tenant switcher is searchable (typeahead); favorites (if enabled) appear at the top. +- **UX-003**: Portfolio table includes (at minimum): Name, Tenant ID (short/copy), Environment, Connection/App status, RBAC/Health indicator, Last Sync (time), Policies count; optional Restore runs (last 30d). +- **UX-004**: Portfolio “Open” action makes the tenant context explicit and navigates into the tenant-scoped area. +- **UX-005**: Restore screens show “Target Tenant” prominently (name + environment badge) and require tenant-aware type-to-confirm (e.g. `RESTORE PROD`). + +### Data Model Requirements +- **DM-001**: Introduce tenant access/membership mapping (user ↔ tenant) with a role (`owner|manager|operator|readonly`). +- **DM-002**: Add tenant environment classification (`prod|dev|staging|other`) as a first-class attribute (column or indexed JSONB). +- **DM-003 (Optional)**: Persist per-user tenant preferences (favorites + last used) without coupling it to cross-tab safety. +- **DM-004 (Optional)**: Support grouping tenants by customer (MSP use case) via a lightweight “customer label” or a dedicated Customer model (future). + +## Non-Goals +- No multi-tenant policy detail view in one screen. +- No multi-tenant restore; restore run always targets exactly one tenant. +- No cross-tenant diff/promotion (separate feature). + +## Success Criteria +- **SC-001**: A user can switch Current Tenant quickly and always understands which tenant they are operating on. +- **SC-002**: All tenant-scoped data is strictly filtered and authorization-safe. +- **SC-003**: Bulk Sync works across selected tenants with clear feedback and role gating. diff --git a/specs/031-tenant-portfolio-context-switch/tasks.md b/specs/031-tenant-portfolio-context-switch/tasks.md new file mode 100644 index 0000000..70acd11 --- /dev/null +++ b/specs/031-tenant-portfolio-context-switch/tasks.md @@ -0,0 +1,33 @@ +# Tasks: Tenant Portfolio & Context Switch (031) + +**Branch**: `feat/031-tenant-portfolio-context-switch` +**Date**: 2026-01-04 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md) + +## Phase 1: Setup +- [x] T001 Create spec/plan/tasks and checklist. + +## Phase 2: Research & Design +- [x] T002 Review Filament tenancy support and choose the context mechanism (route vs session). +- [x] T003 Define tenant access roles and mapping (user memberships; future org/group principals). +- [x] T004 Decide how to store `environment` (column vs JSONB) and whether MSP “customer grouping” is in scope. +- [x] T005 Define context precedence rules (env override, route tenant, session/default tenant) and cross-tab safety expectations. + +## Phase 3: Tests (TDD) +- [x] T006 Authorization: user cannot access unauthorized tenant (404). +- [x] T007 Authorization: tenant-scoped resources deny cross-tenant access via URL (404). +- [x] T008 Context switching: “Open tenant” navigates into tenant-scoped pages (tenant in URL) and data filters correctly. +- [x] T009 Bulk sync: dispatches one job per selected tenant; readonly role cannot run it. +- [ ] T010 UI (optional browser tests): tenant switcher visible and environment badge shown. + +## Phase 4: Implementation +- [x] T011 Add migrations for tenant memberships/roles and environment attribute (and optional preferences). +- [x] T012 Implement `TenantContext` + authorization gate/policy (`canAccessTenant`). +- [x] T013 Integrate tenant switcher into Filament topbar and make Current Tenant always visible. +- [x] T014 Scope tenant resources (Policies/Backups/RestoreRuns/etc.) via TenantContext; replace direct `Tenant::current()` usage. +- [x] T015 Update `TenantResource` into a portfolio view: access-scoped query, columns, filters, “Open”, “Sync”, bulk “Sync selected”. +- [x] T016 Add restore guardrails (target tenant header + tenant-aware confirmations). + +## Phase 5: Verification +- [x] T017 Run targeted tests. +- [x] T018 Run Pint (`./vendor/bin/pint --dirty`). diff --git a/tests/Feature/BulkDeleteBackupSetsTest.php b/tests/Feature/BulkDeleteBackupSetsTest.php index 260b458..b3d81bd 100644 --- a/tests/Feature/BulkDeleteBackupSetsTest.php +++ b/tests/Feature/BulkDeleteBackupSetsTest.php @@ -7,6 +7,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,8 +15,12 @@ test('backup sets table bulk archive creates a run and archives selected sets', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $sets = collect(range(1, 3))->map(function (int $i) use ($tenant) { return BackupSet::create([ @@ -58,8 +63,12 @@ test('backup sets can be archived even when referenced by restore runs', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $set = BackupSet::create([ 'tenant_id' => $tenant->id, @@ -87,8 +96,12 @@ test('backup sets table bulk archive requires type-to-confirm for 10+ sets', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $sets = collect(range(1, 10))->map(function (int $i) use ($tenant) { return BackupSet::create([ diff --git a/tests/Feature/BulkDeleteMixedStatusTest.php b/tests/Feature/BulkDeleteMixedStatusTest.php index 17dad2b..1a35e66 100644 --- a/tests/Feature/BulkDeleteMixedStatusTest.php +++ b/tests/Feature/BulkDeleteMixedStatusTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('bulk delete restore runs skips running items', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkDeleteRestoreRunsTest.php b/tests/Feature/BulkDeleteRestoreRunsTest.php index 9e41b4b..3ad115c 100644 --- a/tests/Feature/BulkDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkDeleteRestoreRunsTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('bulk delete restore runs soft deletes selected runs', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkForceDeleteBackupSetsTest.php b/tests/Feature/BulkForceDeleteBackupSetsTest.php index 7cb8aec..abc6316 100644 --- a/tests/Feature/BulkForceDeleteBackupSetsTest.php +++ b/tests/Feature/BulkForceDeleteBackupSetsTest.php @@ -6,6 +6,7 @@ use App\Models\BulkOperationRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('backup sets table bulk force delete permanently deletes archived sets and their items', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $set = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkForceDeletePolicyVersionsTest.php b/tests/Feature/BulkForceDeletePolicyVersionsTest.php index 106239b..701bd44 100644 --- a/tests/Feature/BulkForceDeletePolicyVersionsTest.php +++ b/tests/Feature/BulkForceDeletePolicyVersionsTest.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,6 +15,11 @@ test('policy versions table bulk force delete creates a run and skips non-archived records', function () { $tenant = Tenant::factory()->create(['is_current' => true]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $version = PolicyVersion::factory()->create([ diff --git a/tests/Feature/BulkForceDeleteRestoreRunsTest.php b/tests/Feature/BulkForceDeleteRestoreRunsTest.php index d527954..cd383d8 100644 --- a/tests/Feature/BulkForceDeleteRestoreRunsTest.php +++ b/tests/Feature/BulkForceDeleteRestoreRunsTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('bulk force delete restore runs permanently deletes archived runs', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkPruneSkipReasonsTest.php b/tests/Feature/BulkPruneSkipReasonsTest.php index 759ff54..de35dcb 100644 --- a/tests/Feature/BulkPruneSkipReasonsTest.php +++ b/tests/Feature/BulkPruneSkipReasonsTest.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,6 +15,11 @@ test('bulk prune records skip reasons', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policyA = Policy::factory()->create(['tenant_id' => $tenant->id]); $current = PolicyVersion::factory()->create([ @@ -37,8 +43,6 @@ 'captured_at' => now()->subDays(10), ]); - $tenant->forceFill(['is_current' => true])->save(); - Livewire::actingAs($user) ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) ->callTableBulkAction('bulk_prune_versions', collect([$current, $tooRecent]), data: [ diff --git a/tests/Feature/BulkPruneVersionsTest.php b/tests/Feature/BulkPruneVersionsTest.php index ec62444..d9cbe48 100644 --- a/tests/Feature/BulkPruneVersionsTest.php +++ b/tests/Feature/BulkPruneVersionsTest.php @@ -5,6 +5,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,6 +14,11 @@ test('bulk prune archives eligible policy versions', function () { $tenant = Tenant::factory()->create(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); @@ -30,8 +36,6 @@ 'captured_at' => now()->subDays(120), ]); - $tenant->forceFill(['is_current' => true])->save(); - Livewire::actingAs($user) ->test(PolicyVersionResource\Pages\ListPolicyVersions::class) ->callTableBulkAction('bulk_prune_versions', collect([$eligible, $current]), data: [ diff --git a/tests/Feature/BulkRestoreBackupSetsTest.php b/tests/Feature/BulkRestoreBackupSetsTest.php index 3908e6d..6132119 100644 --- a/tests/Feature/BulkRestoreBackupSetsTest.php +++ b/tests/Feature/BulkRestoreBackupSetsTest.php @@ -6,6 +6,7 @@ use App\Models\BulkOperationRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('backup sets table bulk restore restores archived sets and their items', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $set = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkRestorePolicyVersionsTest.php b/tests/Feature/BulkRestorePolicyVersionsTest.php index 41d6a81..8f3b429 100644 --- a/tests/Feature/BulkRestorePolicyVersionsTest.php +++ b/tests/Feature/BulkRestorePolicyVersionsTest.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -14,6 +15,11 @@ test('policy versions table bulk restore creates a run and restores archived records', function () { $tenant = Tenant::factory()->create(['is_current' => true]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); $version = PolicyVersion::factory()->create([ diff --git a/tests/Feature/BulkRestoreRestoreRunsTest.php b/tests/Feature/BulkRestoreRestoreRunsTest.php index 35d2bec..a6fa5e9 100644 --- a/tests/Feature/BulkRestoreRestoreRunsTest.php +++ b/tests/Feature/BulkRestoreRestoreRunsTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,8 +14,12 @@ test('restore runs table bulk restore creates a run and restores archived records', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, diff --git a/tests/Feature/BulkTypeToConfirmTest.php b/tests/Feature/BulkTypeToConfirmTest.php index 1b7748a..43a78a9 100644 --- a/tests/Feature/BulkTypeToConfirmTest.php +++ b/tests/Feature/BulkTypeToConfirmTest.php @@ -4,6 +4,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -11,8 +12,12 @@ test('bulk delete requires confirmation string for large batches', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) @@ -27,8 +32,12 @@ test('bulk delete fails with incorrect confirmation string', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(20)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) @@ -43,8 +52,12 @@ test('bulk delete does not require confirmation string for small batches', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) diff --git a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php index 809b9ed..8f00a23 100644 --- a/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/AppProtectionPolicySettingsDisplayTest.php @@ -47,9 +47,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('Data Protection'); diff --git a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php index 7ad9258..0ed7cc6 100644 --- a/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php +++ b/tests/Feature/Filament/EnrollmentAutopilotSettingsDisplayTest.php @@ -22,6 +22,9 @@ $this->tenant = $tenant; $this->user = User::factory()->create(); + $this->user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); }); test('policy detail renders normalized settings for Autopilot profiles', function () { @@ -54,7 +57,7 @@ ]); $response = $this->actingAs($this->user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant)); $response->assertOk(); $response->assertSee('Settings'); @@ -95,7 +98,7 @@ ]); $response = $this->actingAs($this->user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant)); $response->assertOk(); $response->assertSee('Settings'); @@ -139,7 +142,7 @@ ]); $response = $this->actingAs($this->user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $this->tenant)); $response->assertOk(); $response->assertSee('Settings'); diff --git a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php index e7b424e..3254355 100644 --- a/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php +++ b/tests/Feature/Filament/GroupPolicyConfigurationHydrationTest.php @@ -133,10 +133,13 @@ public function request(string $method, string $path, array $options = []): Grap ); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy]))); $response->assertOk(); $response->assertSee('Block legacy auth'); diff --git a/tests/Feature/Filament/HousekeepingTest.php b/tests/Feature/Filament/HousekeepingTest.php index fb2a62e..7c2bd74 100644 --- a/tests/Feature/Filament/HousekeepingTest.php +++ b/tests/Feature/Filament/HousekeepingTest.php @@ -12,6 +12,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -43,6 +44,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet); @@ -78,6 +83,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet); @@ -117,6 +126,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet) @@ -158,6 +171,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListBackupSets::class) ->callTableAction('archive', $backupSet) @@ -197,6 +214,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListRestoreRuns::class) ->callTableAction('archive', $restoreRun) @@ -235,6 +256,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListRestoreRuns::class) ->callTableAction('archive', $restoreRun) @@ -269,6 +294,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListPolicies::class) ->callTableAction('ignore', $policy); @@ -309,6 +338,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListPolicyVersions::class) ->callTableAction('archive', $version); @@ -346,6 +379,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListPolicyVersions::class) ->callTableAction('archive', $version) @@ -368,6 +405,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ListTenants::class) ->callTableAction('archive', $tenant); @@ -409,6 +450,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $active->getKey() => ['role' => 'owner'], + $archived->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($active, true); $component = Livewire::test(ListTenants::class) ->assertSee($active->name) @@ -433,8 +479,18 @@ $tenant->delete(); + $contextTenant = Tenant::create([ + 'tenant_id' => 'tenant-restore-context', + 'name' => 'Restore Context Tenant', + ]); + $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $contextTenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($contextTenant, true); Livewire::test(ListTenants::class) ->set('tableFilters.trashed.value', 1) diff --git a/tests/Feature/Filament/MalformedSnapshotWarningTest.php b/tests/Feature/Filament/MalformedSnapshotWarningTest.php index 07bcea1..cd5abbb 100644 --- a/tests/Feature/Filament/MalformedSnapshotWarningTest.php +++ b/tests/Feature/Filament/MalformedSnapshotWarningTest.php @@ -41,14 +41,17 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $policyResponse->assertSee('This snapshot may be incomplete or malformed'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $versionResponse->assertSee('This snapshot may be incomplete or malformed'); }); diff --git a/tests/Feature/Filament/ODataTypeMismatchTest.php b/tests/Feature/Filament/ODataTypeMismatchTest.php index 3aca519..1d1d8d5 100644 --- a/tests/Feature/Filament/ODataTypeMismatchTest.php +++ b/tests/Feature/Filament/ODataTypeMismatchTest.php @@ -100,9 +100,12 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $detailResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $detailResponse->assertSee('@odata.type mismatch'); diff --git a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php index f547b1b..6b7227c 100644 --- a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php +++ b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php @@ -7,6 +7,7 @@ use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\ScopeTagResolver; use App\Services\Intune\PolicySnapshotService; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -22,6 +23,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy) { $mock->shouldReceive('fetch') diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index 72ba98f..49c2e81 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -7,13 +7,7 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class); test('policies are listed for the active tenant', function () { - $tenant = Tenant::create([ - 'tenant_id' => 'local-tenant', - 'name' => 'Tenant One', - 'metadata' => [], - ]); - - $tenant->makeCurrent(); + $tenant = Tenant::factory()->create(); Policy::create([ 'tenant_id' => $tenant->id, @@ -24,11 +18,7 @@ 'last_synced_at' => now(), ]); - $otherTenant = Tenant::create([ - 'tenant_id' => 'tenant-2', - 'name' => 'Tenant Two', - 'metadata' => [], - ]); + $otherTenant = Tenant::factory()->create(); Policy::create([ 'tenant_id' => $otherTenant->id, @@ -40,9 +30,13 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user) - ->get(route('filament.admin.resources.policies.index')) + ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee('Policy A') ->assertDontSee('Policy B'); diff --git a/tests/Feature/Filament/PolicySettingsDisplayTest.php b/tests/Feature/Filament/PolicySettingsDisplayTest.php index 56acd96..1d2c934 100644 --- a/tests/Feature/Filament/PolicySettingsDisplayTest.php +++ b/tests/Feature/Filament/PolicySettingsDisplayTest.php @@ -49,9 +49,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('Settings'); diff --git a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php index 566c323..dcd3999 100644 --- a/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php +++ b/tests/Feature/Filament/PolicySettingsStandardRendersArraysTest.php @@ -48,9 +48,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); $response->assertOk(); $response->assertSee('Settings'); diff --git a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php index e53e7ab..516bdc8 100644 --- a/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php +++ b/tests/Feature/Filament/PolicyVersionReadableLayoutTest.php @@ -58,9 +58,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $response->assertOk(); $response->assertSee('Normalized settings'); diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php index 7a84965..02e9efe 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -10,6 +10,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GroupResolver; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -49,11 +50,15 @@ ]); $user = User::factory()->create(['email' => 'tester@example.com']); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); + Filament::setTenant($tenant, true); Livewire::test(ListPolicyVersions::class) ->callTableAction('restore_via_wizard', $version) - ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false)); + ->assertRedirectContains(RestoreRunResource::getUrl('create', [], false, tenant: $tenant)); $backupSet = BackupSet::query()->where('metadata->source', 'policy_version')->first(); expect($backupSet)->not->toBeNull(); @@ -141,7 +146,11 @@ }); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); + Filament::setTenant($tenant, true); $component = Livewire::withQueryParams([ 'backup_set_id' => $backupSet->id, diff --git a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php index 63a3dce..2f96209 100644 --- a/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php +++ b/tests/Feature/Filament/PolicyVersionScopeTagsDisplayTest.php @@ -47,9 +47,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $response->assertOk(); $response->assertSee('Scope Tags'); diff --git a/tests/Feature/Filament/PolicyVersionSettingsTest.php b/tests/Feature/Filament/PolicyVersionSettingsTest.php index a7495c7..79c9d71 100644 --- a/tests/Feature/Filament/PolicyVersionSettingsTest.php +++ b/tests/Feature/Filament/PolicyVersionSettingsTest.php @@ -45,9 +45,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $response->assertOk(); $response->assertSee('Raw JSON'); @@ -132,9 +135,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings'); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings'); $response->assertOk(); $response->assertSee('Enrollment notifications'); diff --git a/tests/Feature/Filament/PolicyVersionTest.php b/tests/Feature/Filament/PolicyVersionTest.php index ed14f79..4a104e9 100644 --- a/tests/Feature/Filament/PolicyVersionTest.php +++ b/tests/Feature/Filament/PolicyVersionTest.php @@ -31,9 +31,12 @@ $service->captureVersion($policy, ['value' => 2], 'tester'); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user) - ->get(route('filament.admin.resources.policy-versions.index')) + ->get(route('filament.admin.resources.policy-versions.index', filamentTenantRouteParams($tenant))) ->assertOk() ->assertSee('Policy A') ->assertSee((string) PolicyVersion::max('version_number')); diff --git a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php index 49b7cbd..bfbebb7 100644 --- a/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php +++ b/tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php @@ -71,9 +71,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('Settings'); // Settings tab should appear for Settings Catalog @@ -130,9 +133,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // TODO: Manual verification - check UI for display name "Allow Real-time Monitoring" @@ -181,9 +187,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // TODO: Manual verification - check UI shows prettified fallback label @@ -225,9 +234,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); $response->assertSee('General'); @@ -281,8 +293,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Policy view should render successfully with Settings Catalog data @@ -356,8 +371,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Value formatting verified by manual UI inspection @@ -419,8 +437,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Search functionality is Alpine.js client-side, requires browser testing @@ -465,8 +486,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); // Page renders without crash - actual fallback display requires UI verification diff --git a/tests/Feature/Filament/RestoreItemSelectionTest.php b/tests/Feature/Filament/RestoreItemSelectionTest.php index 7ac2911..a80766d 100644 --- a/tests/Feature/Filament/RestoreItemSelectionTest.php +++ b/tests/Feature/Filament/RestoreItemSelectionTest.php @@ -7,6 +7,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -99,6 +100,10 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php index 7673fe7..9b0e106 100644 --- a/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php +++ b/tests/Feature/Filament/ScriptPoliciesNormalizedDisplayTest.php @@ -12,9 +12,12 @@ $originalEnv = getenv('INTUNE_TENANT_ID'); putenv('INTUNE_TENANT_ID='); - $this->actingAs(User::factory()->create()); - $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); @@ -51,10 +54,10 @@ ], ]); - $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index')) + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('index', tenant: $tenant)) ->assertSuccessful(); - $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version]).'?tab=normalized-settings') + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant).'?tab=normalized-settings') ->assertSuccessful(); $originalEnv !== false @@ -71,14 +74,17 @@ $originalEnv = getenv('INTUNE_TENANT_ID'); putenv('INTUNE_TENANT_ID='); - $this->actingAs(User::factory()->create()); - config([ 'tenantpilot.display.show_script_content' => true, 'tenantpilot.display.max_script_content_chars' => 5000, ]); $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); @@ -117,7 +123,7 @@ ], ]); - $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); $this->get($url.'?tab=diff') ->assertSuccessful() @@ -136,14 +142,17 @@ $originalEnv = getenv('INTUNE_TENANT_ID'); putenv('INTUNE_TENANT_ID='); - $this->actingAs(User::factory()->create()); - config([ 'tenantpilot.display.show_script_content' => true, 'tenantpilot.display.max_script_content_chars' => 5000, ]); $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); putenv('INTUNE_TENANT_ID='.$tenant->tenant_id); $tenant->makeCurrent(); @@ -182,7 +191,7 @@ ], ]); - $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2]); + $url = \App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $v2], tenant: $tenant); $this->get($url.'?tab=diff') ->assertSuccessful() diff --git a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php index 67c0793..0f6f0cf 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyHydrationTest.php @@ -105,10 +105,13 @@ public function request(string $method, string $path, array $options = []): Grap ); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy]))); $response->assertOk(); $response->assertSee('Setting A'); @@ -145,10 +148,13 @@ public function request(string $method, string $path, array $options = []): Grap $versions->captureFromGraph($tenant, $policy, createdBy: 'tester@example.com'); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.view', ['record' => $policy])); + ->get(route('filament.admin.resources.policies.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $policy]))); $response->assertOk(); $response->assertSee('Setting A'); diff --git a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php index a83f411..0c1dadf 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicyNormalizedDisplayTest.php @@ -90,9 +90,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $policyResponse->assertOk(); $policyResponse->assertSee('Definition'); @@ -104,7 +107,7 @@ $policyResponse->assertSee('tp-policy-general-card'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $versionResponse->assertOk(); $versionResponse->assertSee('Normalized settings'); diff --git a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index f34e7c3..c8dca6f 100644 --- a/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -111,10 +111,13 @@ public function request(string $method, string $path, array $options = []): Grap ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this ->actingAs($user) - ->get(route('filament.admin.resources.policies.index')); + ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($tenant))); $response->assertOk(); $response->assertSee('Settings Catalog Policy'); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index ec69858..68e7dcd 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -147,6 +147,9 @@ public function request(string $method, string $path, array $options = []): Grap ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); $service = app(RestoreService::class); @@ -185,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap $run->update(['results' => $results]); - $response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); + $response = $this->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response->assertOk(); $response->assertSee('Graph bulk apply failed'); $response->assertSee('Setting missing'); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 0570dad..5b37350 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -162,6 +162,9 @@ public function request(string $method, string $path, array $options = []): Grap ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $this->actingAs($user); $service = app(RestoreService::class); @@ -201,7 +204,7 @@ public function request(string $method, string $path, array $options = []): Grap ->toBe('#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance'); $response = $this - ->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); + ->get(route('filament.admin.resources.restore-runs.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $run]))); $response->assertOk(); $response->assertSee('settings are read-only'); diff --git a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php index ddc67fb..0bab050 100644 --- a/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php +++ b/tests/Feature/Filament/SettingsCatalogSettingsTableRenderTest.php @@ -56,9 +56,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $policyResponse = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy]).'?tab=settings'); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant).'?tab=settings'); $policyResponse->assertOk(); $policyResponse->assertSee('fi-width-full'); @@ -69,7 +72,7 @@ $policyResponse->assertSee('fi-ta-table'); $versionResponse = $this->actingAs($user) - ->get(PolicyVersionResource::getUrl('view', ['record' => $version])); + ->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)); $versionResponse->assertOk(); $versionResponse->assertSee('fi-width-full'); diff --git a/tests/Feature/Filament/TenantMakeCurrentTest.php b/tests/Feature/Filament/TenantMakeCurrentTest.php index 8da5bbc..d9e000f 100644 --- a/tests/Feature/Filament/TenantMakeCurrentTest.php +++ b/tests/Feature/Filament/TenantMakeCurrentTest.php @@ -3,6 +3,7 @@ use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -26,6 +27,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $first->getKey() => ['role' => 'owner'], + $second->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($first, true); Livewire::test(ListTenants::class) ->callTableAction('makeCurrent', $second); diff --git a/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php new file mode 100644 index 0000000..c95165c --- /dev/null +++ b/tests/Feature/Filament/TenantPortfolioContextSwitchTest.php @@ -0,0 +1,104 @@ +create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.policies.index', filamentTenantRouteParams($unauthorizedTenant))) + ->assertNotFound(); +}); + +test('tenant portfolio lists only tenants the user can access', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $authorizedTenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-portfolio-authorized', + 'name' => 'Authorized Tenant', + ]); + + $unauthorizedTenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-portfolio-unauthorized', + 'name' => 'Unauthorized Tenant', + ]); + + $user->tenants()->syncWithoutDetaching([ + $authorizedTenant->getKey() => ['role' => 'owner'], + ]); + + $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($authorizedTenant))) + ->assertOk() + ->assertSee($authorizedTenant->name) + ->assertDontSee($unauthorizedTenant->name); +}); + +test('tenant portfolio bulk sync dispatches one job per eligible tenant', function () { + Bus::fake(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $tenantA = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-a']); + $tenantB = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-b']); + + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'operator'], + ]); + + Filament::setTenant($tenantA, true); + + Livewire::test(ListTenants::class) + ->assertTableBulkActionVisible('syncSelected') + ->callTableBulkAction('syncSelected', collect([$tenantA, $tenantB])); + + Bus::assertDispatchedTimes(BulkTenantSyncJob::class, 1); + + $this->assertDatabaseHas('bulk_operation_runs', [ + 'tenant_id' => $tenantA->id, + 'user_id' => $user->id, + 'resource' => 'tenant', + 'action' => 'sync', + 'total_items' => 2, + ]); +}); + +test('tenant portfolio bulk sync is hidden for readonly users', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $tenant = Tenant::factory()->create(['tenant_id' => 'tenant-bulk-readonly']); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + Filament::setTenant($tenant, true); + + Livewire::test(ListTenants::class) + ->assertTableBulkActionHidden('syncSelected'); +}); + +test('tenant set event updates user tenant preference last used timestamp', function () { + [$user, $tenant] = createUserWithTenant(); + + TenantSet::dispatch($tenant, $user); + + $this->assertDatabaseHas('user_tenant_preferences', [ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + ]); +}); diff --git a/tests/Feature/Filament/TenantRbacWizardTest.php b/tests/Feature/Filament/TenantRbacWizardTest.php index dd55e28..d9cd858 100644 --- a/tests/Feature/Filament/TenantRbacWizardTest.php +++ b/tests/Feature/Filament/TenantRbacWizardTest.php @@ -6,6 +6,7 @@ use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Livewire\Livewire; @@ -32,6 +33,10 @@ function tenantWithApp(): Tenant $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') @@ -51,6 +56,10 @@ function tenantWithApp(): Tenant $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -155,6 +164,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -265,6 +278,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -365,6 +382,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') @@ -380,6 +401,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->mountAction('setup_rbac') @@ -394,6 +419,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); @@ -505,6 +534,10 @@ public function request(string $method, string $path, array $options = []): Grap $tenant = tenantWithApp(); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($tenant, true); $cacheKey = RbacDelegatedAuthController::cacheKey($tenant, $user->id, null); Cache::put($cacheKey, 'delegated-token', now()->addMinutes(5)); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 10c6ec8..b950406 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -54,9 +55,19 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); + $contextTenant = Tenant::create([ + 'tenant_id' => 'tenant-context', + 'name' => 'Context Tenant', + ]); + $user->tenants()->syncWithoutDetaching([ + $contextTenant->getKey() => ['role' => 'owner'], + ]); + Filament::setTenant($contextTenant, true); + Livewire::test(CreateTenant::class) ->fillForm([ 'name' => 'Contoso', + 'environment' => 'other', 'tenant_id' => 'tenant-guid', 'domain' => 'contoso.com', 'app_client_id' => 'client-123', @@ -65,7 +76,7 @@ public function request(string $method, string $path, array $options = []): Grap ->call('create') ->assertHasNoFormErrors(); - $tenant = Tenant::first(); + $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first(); expect($tenant)->not->toBeNull(); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) @@ -129,6 +140,11 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-error', 'name' => 'Error Tenant', ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('verify'); @@ -157,6 +173,9 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-ui', 'name' => 'UI Tenant', ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); config(['intune_permissions.granted_stub' => []]); @@ -169,7 +188,7 @@ public function request(string $method, string $path, array $options = []): Grap 'status' => 'ok', ]); - $response = $this->get(route('filament.admin.resources.tenants.view', $tenant)); + $response = $this->get(route('filament.admin.resources.tenants.view', array_merge(filamentTenantRouteParams($tenant), ['record' => $tenant]))); $response->assertOk(); $response->assertSee('Actions'); @@ -182,13 +201,17 @@ public function request(string $method, string $path, array $options = []): Grap $user = User::factory()->create(); $this->actingAs($user); - Tenant::create([ + $tenant = Tenant::create([ 'tenant_id' => 'tenant-ui-list', 'name' => 'UI Tenant List', 'app_client_id' => 'client-123', ]); - $response = $this->get(route('filament.admin.resources.tenants.index')); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $response = $this->get(route('filament.admin.resources.tenants.index', filamentTenantRouteParams($tenant))); $response->assertOk(); $response->assertSee('Open in Entra'); @@ -202,6 +225,11 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => 'tenant-ui-deactivate', 'name' => 'UI Tenant Deactivate', ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) ->callAction('archive'); diff --git a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php index fccc528..bfc70b8 100644 --- a/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php +++ b/tests/Feature/Filament/WindowsUpdateRingPolicyTest.php @@ -48,9 +48,12 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); $response = $this->actingAs($user) - ->get(PolicyResource::getUrl('view', ['record' => $policy])); + ->get(PolicyResource::getUrl('view', ['record' => $policy], tenant: $tenant)); $response->assertOk(); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index ff174ad..5b6cdad 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -10,11 +10,13 @@ unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']); $this->tenant = Tenant::factory()->create(); - $this->tenant->makeCurrent(); $this->policy = Policy::factory()->create([ 'tenant_id' => $this->tenant->id, ]); $this->user = User::factory()->create(); + $this->user->tenants()->syncWithoutDetaching([ + $this->tenant->getKey() => ['role' => 'owner'], + ]); }); it('displays policy version page', function () { @@ -26,7 +28,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); }); @@ -67,7 +72,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSeeLivewire('policy-version-assignments-widget'); @@ -87,7 +95,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('Assignments were not captured for this version'); @@ -107,7 +118,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('No assignments found for this version'); @@ -137,7 +151,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('Compliance notifications'); @@ -169,7 +186,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); $response->assertOk(); $response->assertSee('Compliance notifications'); @@ -192,7 +212,10 @@ $this->actingAs($this->user); - $response = $this->get("/admin/policy-versions/{$version->id}?tab=normalized-settings"); + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + )).'?tab=normalized-settings'); $response->assertOk(); $response->assertSee('Password & Access'); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index 5746b5b..1ddd818 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GroupResolver; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -76,6 +77,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -157,6 +163,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestorePreviewDiffWizardTest.php b/tests/Feature/RestorePreviewDiffWizardTest.php index ab62af1..90c0caa 100644 --- a/tests/Feature/RestorePreviewDiffWizardTest.php +++ b/tests/Feature/RestorePreviewDiffWizardTest.php @@ -8,6 +8,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -86,6 +87,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index 1fe987b..32a88f0 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GroupResolver; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Mockery\MockInterface; @@ -77,6 +78,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -188,6 +194,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -270,6 +281,11 @@ $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $component = Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestoreRunArchiveGuardTest.php b/tests/Feature/RestoreRunArchiveGuardTest.php index 50c7c57..0520342 100644 --- a/tests/Feature/RestoreRunArchiveGuardTest.php +++ b/tests/Feature/RestoreRunArchiveGuardTest.php @@ -5,6 +5,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -28,6 +29,11 @@ ]); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::actingAs($user) ->test(ListRestoreRuns::class) diff --git a/tests/Feature/RestoreRunRerunTest.php b/tests/Feature/RestoreRunRerunTest.php index a6014ef..924a03a 100644 --- a/tests/Feature/RestoreRunRerunTest.php +++ b/tests/Feature/RestoreRunRerunTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -13,7 +14,6 @@ test('rerun action creates a new restore run with the same selections', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $backupSet = BackupSet::factory()->for($tenant)->create([ 'status' => 'completed', @@ -47,6 +47,11 @@ ]); $user = User::factory()->create(['email' => 'tester@example.com']); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::actingAs($user) ->test(ListRestoreRuns::class) diff --git a/tests/Feature/RestoreRunWizardExecuteTest.php b/tests/Feature/RestoreRunWizardExecuteTest.php index 4332917..8528c5c 100644 --- a/tests/Feature/RestoreRunWizardExecuteTest.php +++ b/tests/Feature/RestoreRunWizardExecuteTest.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Models\User; use App\Support\RestoreRunStatus; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Bus; use Livewire\Livewire; @@ -62,6 +63,11 @@ 'name' => 'Tester', ]); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ @@ -130,6 +136,11 @@ 'name' => 'Executor', ]); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Feature/RestoreRunWizardMetadataTest.php b/tests/Feature/RestoreRunWizardMetadataTest.php index 10c9697..9d29991 100644 --- a/tests/Feature/RestoreRunWizardMetadataTest.php +++ b/tests/Feature/RestoreRunWizardMetadataTest.php @@ -6,6 +6,7 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -50,6 +51,11 @@ 'name' => 'Tester', ]); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); Livewire::test(CreateRestoreRun::class) ->fillForm([ diff --git a/tests/Pest.php b/tests/Pest.php index 4baf965..62cf209 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ create(); + $tenant ??= Tenant::factory()->create(); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => $role], + ]); + + return [$user, $tenant]; +} + +/** + * @return array{tenant: string} + */ +function filamentTenantRouteParams(Tenant $tenant): array +{ + return ['tenant' => (string) $tenant->external_id]; +} diff --git a/tests/Unit/BulkActionPermissionTest.php b/tests/Unit/BulkActionPermissionTest.php index a9da5b9..02e99b6 100644 --- a/tests/Unit/BulkActionPermissionTest.php +++ b/tests/Unit/BulkActionPermissionTest.php @@ -4,6 +4,7 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; +use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; use Tests\TestCase; @@ -12,8 +13,12 @@ test('policies bulk actions are available for authenticated users', function () { $tenant = Tenant::factory()->create(); - $tenant->makeCurrent(); $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); $policies = Policy::factory()->count(2)->create(['tenant_id' => $tenant->id]); Livewire::actingAs($user) From beffbfca4c1011194695413472f0ae5af402c995 Mon Sep 17 00:00:00 2001 From: ahmido Date: Sun, 4 Jan 2026 23:54:56 +0000 Subject: [PATCH 16/18] feat/032-backup-scheduling-mvp (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ziel: MVP-Spezifikation für “Automatisierte Backups per Zeitplan (pro Tenant)” als Grundlage für die Implementierung (Spec-first). Scope (MVP): Tenant-scoped backup_schedules + backup_schedule_runs Dispatcher erstellt idempotente Runs (Unique Slot) + Queue-Job führt Run aus “Run now” / “Retry”, Run-History, Retention (keep last N) No catch-up für verpasste Slots Wichtige Klarstellungen (aus Constitution abgeleitet): Jede Operation ist tenant-scoped und schreibt Audit Logs (Dispatcher/Run/Retention; keine Secrets/Tokens) Graph-Aufrufe laufen über die bestehende Abstraktion (keine Hardcodings) Retry/Backoff: Throttling → Backoff; 401/403 → kein Retry Authorization (MVP): TenantRole-Matrix (readonly/operator/manager/owner) statt neuer Permission-Registry Nicht im MVP: Kein Restore-Scheduling Kein Cross-Tenant Bulk Scheduling / Templates Kein Catch-up von missed runs Review-Fokus: Semantik “1 Run = 1 BackupSet” Concurrency/Lock-Verhalten (bei laufendem Run → skipped) DST/Timezone-Regeln + Slot-Minutenpräzision Artefakte: spec.md plan.md tasks.md requirements.md Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/33 --- .../checklists/requirements.md | 13 ++ specs/032-backup-scheduling-mvp/plan.md | 67 ++++++++++ specs/032-backup-scheduling-mvp/spec.md | 117 ++++++++++++++++++ specs/032-backup-scheduling-mvp/tasks.md | 42 +++++++ 4 files changed, 239 insertions(+) create mode 100644 specs/032-backup-scheduling-mvp/checklists/requirements.md create mode 100644 specs/032-backup-scheduling-mvp/plan.md create mode 100644 specs/032-backup-scheduling-mvp/spec.md create mode 100644 specs/032-backup-scheduling-mvp/tasks.md diff --git a/specs/032-backup-scheduling-mvp/checklists/requirements.md b/specs/032-backup-scheduling-mvp/checklists/requirements.md new file mode 100644 index 0000000..db99631 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/checklists/requirements.md @@ -0,0 +1,13 @@ +# Requirements Checklist (032) + +- [ ] Tenant-scoped tables use `tenant_id` consistently. +- [ ] 1 Run = 1 BackupSet (no rolling reuse in MVP). +- [ ] Dispatcher is idempotent (unique schedule_id + scheduled_for). +- [ ] Concurrency lock prevents parallel runs per schedule. +- [ ] Run stores status + summary + error_code/error_message. +- [ ] UI shows schedule list + run history + link to backup set. +- [ ] Run now + Retry are permission-gated and write DB notifications. +- [ ] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). +- [ ] Retry/backoff policy implemented (no retry for 401/403). +- [ ] Retention keeps last N and soft-deletes older backup sets. +- [ ] Tests cover due-calculation, idempotency, job success/failure, retention. diff --git a/specs/032-backup-scheduling-mvp/plan.md b/specs/032-backup-scheduling-mvp/plan.md new file mode 100644 index 0000000..468d288 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/plan.md @@ -0,0 +1,67 @@ +# Plan: Backup Scheduling MVP (032) + +**Date**: 2026-01-05 +**Input**: spec.md + +## Architecture / Reuse +- Reuse existing services: + - `PolicySyncService::syncPoliciesWithReport()` for selected policy types + - `BackupService::createBackupSet()` to create immutable snapshots + items (include_foundations supported) +- Store selection as `policy_types` (config keys), not free-form categories. +- Use tenant scoping (`tenant_id`) consistent with existing tables (`backup_sets`, `backup_items`). + +## Scheduling Mechanism +- Add Artisan command: `tenantpilot:schedules:dispatch`. +- Scheduler integration (Laravel 12): schedule the command every minute via `routes/console.php` + ops configuration (Dokploy cron `schedule:run` or long-running `schedule:work`). +- Dispatcher algorithm: + 1) load enabled schedules + 2) compute whether due for the current minute in schedule timezone + 3) create run with `scheduled_for` slot (minute precision) using DB unique constraint + 4) dispatch `RunBackupScheduleJob(schedule_id, run_id)` +- Concurrency: + - Cache lock per schedule (`lock:backup_schedule:{id}`) plus DB unique slot constraint for idempotency. + - If lock is held: mark run as `skipped` with a clear error_code (no parallel execution). + +## Run Execution +- `RunBackupScheduleJob`: + 1) load schedule + tenant + 2) preflight: tenant active; Graph/auth errors mapped to error_code + 3) sync policies for selected types (collect report) + 4) select policy IDs from local DB for those types (exclude ignored) + 5) create backup set: + - name: `{schedule_name} - {Y-m-d H:i}` + - includeFoundations: schedule flag + 6) set run status: + - success if backup_set.status == completed + - partial if backup_set.status == partial OR sync had failures but backup succeeded + - failed if nothing backed up / hard error + 7) update schedule last_run_* and compute/persist next_run_at + 8) dispatch retention job + 9) audit logs: + - log run start + completion (status, counts, error_code; no secrets) + +## Retry / Backoff +- Configure job retry behavior based on error classification: + - Throttling/transient (e.g. 429/503): backoff + retry + - Auth/permission (401/403): no retry + - Unknown: limited retries + +## Retention +- `ApplyBackupScheduleRetentionJob(schedule_id)`: + - identify runs ordered newest→oldest + - keep last N runs that created a backup_set_id + - for older ones: soft-delete referenced BackupSets (and cascade soft-delete items) + - audit log: number of deleted BackupSets + +## Filament UX +- Tenant-scoped resources: + - `BackupScheduleResource` + - Runs UI via RelationManager under schedule (or a dedicated resource if needed) +- Actions: enable/disable, run now, retry +- Notifications: persist via `->sendToDatabase($user)` for the DB info panel. + - MVP notification scope: only interactive actions notify the acting user; scheduled runs rely on Run history. + +## Ops / Deployment Notes +- Requires queue worker. +- Requires scheduler running. +- Missed runs policy (MVP): no catch-up. diff --git a/specs/032-backup-scheduling-mvp/spec.md b/specs/032-backup-scheduling-mvp/spec.md new file mode 100644 index 0000000..37f8e8c --- /dev/null +++ b/specs/032-backup-scheduling-mvp/spec.md @@ -0,0 +1,117 @@ +# Feature Specification: Backup Scheduling MVP (032) + +**Feature**: Automatisierte Backups per Zeitplan (pro Tenant) +**Created**: 2026-01-05 +**Status**: Ready for implementation (MVP) +**Risk**: Medium (Backup-only, no restore scheduling) +**Dependencies**: Tenant Portfolio + Tenant Context Switch ✅ + +## Context +TenantPilot unterstützt manuelle Backups. Kunden/MSPs benötigen regelmäßige, zuverlässige Backups pro Tenant (z. B. nightly), inkl. nachvollziehbarer Runs, Fehlercodes und Retention. + +## Goals +- Pro Tenant können 1..n Backup Schedules angelegt werden. +- Schedules laufen automatisch via Queue/Worker. +- Jeder Lauf wird als Run auditierbar gespeichert (Status, Counts, Fehler). +- Retention löscht alte Backups nach Policy. +- Filament UI: Schedules verwalten, Run-History ansehen, “Run now”, “Retry”. + +## Non-Goals (MVP) +- Kein Kalender-UI als Pflicht (kann später ergänzt werden). +- Kein Cross-Tenant Bulk Scheduling (MSP-Templates später). +- Kein “drift-triggered scheduling” (kommt nach Drift-MVP). +- Kein Restore via Scheduling (nur Backup). + +## Definitions +- **Schedule**: Wiederkehrender Plan (daily/weekly, timezone). +- **Run**: Konkrete Ausführung eines Schedules (scheduled_for + status). +- **BackupSet**: Ergebniscontainer eines Runs. + +**MVP Semantik**: **1 Run = 1 neues BackupSet** (kein Rolling-Reuse im MVP). + +## Requirements + +### Functional Requirements +- **FR-001**: Schedules sind tenant-scoped via `tenant_id` (FK auf `tenants.id`). +- **FR-002**: Dispatcher erkennt “due” schedules und erstellt genau einen Run pro Zeit-Slot (idempotent). +- **FR-003**: Run nutzt bestehende Services: + - Sync Policies (nur selektierte policy types) + - Create BackupSet aus lokalen Policy-IDs (inkl. Foundations optional) +- **FR-004**: Run schreibt `backup_schedule_runs` mit Status + Summary + Error-Codes. +- **FR-005**: “Run now” erzeugt sofort einen Run (scheduled_for=now) und dispatcht Job. +- **FR-006**: “Retry” erzeugt einen neuen Run für denselben Schedule. +- **FR-007**: Retention hält nur die letzten N Runs/BackupSets pro Schedule (soft delete BackupSets). +- **FR-008**: Concurrency: Pro Schedule darf nur ein Run gleichzeitig laufen. Wenn bereits ein Run läuft, wird ein neuer Run nicht parallel gestartet und stattdessen als `skipped` markiert (mit Fehlercode). + +### UX Requirements (Filament) +- **UX-001**: Schedule-Liste zeigt Enabled, Frequency, Time+Timezone, Policy Types Summary, Retention, Last Run, Next Run. +- **UX-002**: Run-History pro Schedule zeigt scheduled_for, status, duration, counts, error_code/message, Link zum BackupSet. +- **UX-003**: “Run now” und “Retry” sind nur mit passenden Rechten verfügbar. + +### Security / Authorization +- **SEC-001**: Tenant Isolation: User sieht/managt nur Schedules des aktuellen Tenants. +- **SEC-002 (MVP)**: Authorization erfolgt über TenantRole (wie Tenant Portfolio): + - `readonly`: Schedules ansehen + Runs ansehen + - `operator`: zusätzlich “Run now” / “Retry” + - `manager` / `owner`: zusätzlich Schedules verwalten (CRUD) +- **SEC-003**: Dispatcher, Run-Execution und Retention schreiben tenant-scoped Audit Logs (keine Secrets/Tokens), inkl. Run-Start/Run-Ende und Retention-Ergebnis (z. B. Anzahl gelöschter BackupSets). + +### Reliability / Non-Functional Requirements +- **NFR-001**: Idempotency durch Unique Slot-Constraint (`backup_schedule_id` + `scheduled_for`). +- **NFR-002**: Klare Fehlercodes (z. B. TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN). +- **NFR-003**: Retries: Throttling (z. B. 429/503) → Backoff; 401/403 → kein Retry; Unknown → begrenzte Retries und danach failed. +- **NFR-004**: Missed runs policy (MVP): **No catch-up** — wenn offline, wird nicht nachgeholt, nur nächster Slot. + +### Scheduling Semantics +- `scheduled_for` ist **minute-basiert** (Slot), in UTC gespeichert. Due-Berechnung erfolgt in der Schedule-Timezone. +- DST (MVP): Bei ungültiger lokaler Zeit wird der Slot übersprungen (Run `skipped`). Bei ambiger lokaler Zeit wird die erste Occurrence verwendet. + +## Data Model + +### backup_schedules +- `id` bigint +- `tenant_id` FK tenants.id +- `name` string +- `is_enabled` bool default true +- `timezone` string default 'UTC' +- `frequency` string enum: daily|weekly +- `time_of_day` time +- `days_of_week` json nullable (array, weekly only; 1=Mon..7=Sun) +- `policy_types` jsonb (array) +- `include_foundations` bool default true +- `retention_keep_last` int default 30 +- `last_run_at` datetime nullable +- `last_run_status` string nullable +- `next_run_at` datetime nullable +- timestamps + +Indexes: +- (tenant_id, is_enabled) +- (next_run_at) optional + +### backup_schedule_runs +- `id` bigint +- `backup_schedule_id` FK +- `tenant_id` FK (denormalisiert) +- `scheduled_for` datetime +- `started_at` datetime nullable +- `finished_at` datetime nullable +- `status` string enum: running|success|partial|failed|canceled|skipped +- `summary` jsonb (policies_total, policies_backed_up, errors_count, type_breakdown, warnings) +- `error_code` string nullable +- `error_message` text nullable +- `backup_set_id` FK nullable +- timestamps + +Indexes: +- (backup_schedule_id, scheduled_for) +- (tenant_id, created_at) +- **Unique**: (backup_schedule_id, scheduled_for) + +## Acceptance Criteria +- User kann pro Tenant einen Schedule anlegen (daily/weekly, time, timezone, policy types, retention). +- Dispatcher erstellt Runs zur geplanten Zeit (Queue Worker vorausgesetzt). +- UI zeigt Last Run + Next Run + Run-History. +- Run now startet sofort. +- Fehlerfälle (Token/Permission/Throttle) werden als failed/partial markiert mit error_code. +- Retention hält nur die letzten N BackupSets pro Schedule. diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md new file mode 100644 index 0000000..61cef70 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -0,0 +1,42 @@ +# Tasks: Backup Scheduling MVP (032) + +**Date**: 2026-01-05 +**Input**: spec.md, plan.md + +## Phase 1: Spec & Setup +- [ ] T001 Create specs/032-backup-scheduling-mvp (spec/plan/tasks + checklist). + +## Phase 2: Data Model +- [ ] T002 Add migrations: backup_schedules + backup_schedule_runs (tenant-scoped, indexes, unique slot). +- [ ] T003 Add models + relationships (Tenant->schedules, Schedule->runs, Run->backupSet). + +## Phase 3: Scheduling + Dispatch +- [ ] T004 Add command `tenantpilot:schedules:dispatch`. +- [ ] T005 Register scheduler to run every minute. +- [ ] T006 Implement due-calculation (timezone, daily/weekly) + next_run_at computation. +- [ ] T007 Implement idempotent run creation (unique slot) + cache lock. + +## Phase 4: Jobs +- [ ] T008 Implement `RunBackupScheduleJob` (sync -> select policy IDs -> create backup set -> update run + schedule). +- [ ] T009 Implement `ApplyBackupScheduleRetentionJob` (keep last N, soft-delete backup sets). +- [ ] T010 Add error mapping to `error_code` (TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN). + - [ ] T021 Add audit logging for dispatcher/run/retention (tenant-scoped; no secrets). + - [ ] T022 Implement retry/backoff strategy for `RunBackupScheduleJob` (no retry on 401/403). + +## Phase 5: Filament UI +- [ ] T011 Add `BackupScheduleResource` (tenant-scoped): CRUD + enable/disable. +- [ ] T012 Add Runs UI (relation manager or resource) with details + link to BackupSet. +- [ ] T013 Add actions: Run now + Retry (permission-gated); notifications persisted to DB. + - [ ] T023 Wire authorization to TenantRole (readonly/operator/manager/owner) for schedule CRUD and run actions. + +## Phase 6: Tests +- [ ] T014 Unit: due-calculation + next_run_at. +- [ ] T015 Feature: dispatcher idempotency (unique slot); lock behavior. +- [ ] T016 Job-level: successful run creates backup set, updates run/schedule (Graph mocked). +- [ ] T017 Job-level: token/permission/throttle errors map to error_code and status. +- [ ] T018 Retention: keeps last N and deletes older backup sets. + - [ ] T024 Tests: audit logs written (run success + retention delete) and retry policy behavior. + +## Phase 7: Verification +- [ ] T019 Run targeted tests (Pest). +- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`). From 4d3fcd28a942d837dc2142b5e363fdec3f6823cd Mon Sep 17 00:00:00 2001 From: ahmido Date: Mon, 5 Jan 2026 04:22:13 +0000 Subject: [PATCH 17/18] feat/032-backup-scheduling-mvp (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What Implements tenant-scoped backup scheduling end-to-end: schedules CRUD, minute-based dispatch, queued execution, run history, manual “Run now/Retry”, retention (keep last N), and auditability. Key changes Filament UI: Backup Schedules resource with tenant scoping + SEC-002 role gating. Scheduler + queue: tenantpilot:schedules:dispatch command wired in scheduler (runs every minute), creates idempotent BackupScheduleRun records and dispatches jobs. Execution: RunBackupScheduleJob syncs policies, creates immutable backup sets, updates run status, writes audit logs, applies retry/backoff mapping, and triggers retention. Run history: Relation manager + “View” modal rendering run details. UX polish: row actions grouped; bulk actions grouped (run now / retry / delete). Bulk dispatch writes DB notifications (shows in notifications panel). Validation: policy type hard-validation on save; unknown policy types handled safely at runtime (skipped/partial). Tests: comprehensive Pest coverage for CRUD/scoping/validation, idempotency, job outcomes, error mapping, retention, view modal, run-now/retry notifications, bulk delete (incl. operator forbidden). Files / Areas Filament: BackupScheduleResource.php and app/Filament/Resources/BackupScheduleResource/* Scheduling/Jobs: app/Console/Commands/TenantpilotDispatchBackupSchedules.php, app/Jobs/RunBackupScheduleJob.php, app/Jobs/ApplyBackupScheduleRetentionJob.php, console.php Models/Migrations: app/Models/BackupSchedule.php, app/Models/BackupScheduleRun.php, database/migrations/backup_schedules, backup_schedule_runs Notifications: BackupScheduleRunDispatchedNotification.php Specs: specs/032-backup-scheduling-mvp/* (tasks/checklist/quickstart updates) How to test (Sail) Run tests: ./vendor/bin/sail artisan test tests/Feature/BackupScheduling Run formatter: ./vendor/bin/sail php ./vendor/bin/pint --dirty Apply migrations: ./vendor/bin/sail artisan migrate Manual dispatch: ./vendor/bin/sail artisan tenantpilot:schedules:dispatch Notes Uses DB notifications for queued UI actions to ensure they appear in the notifications panel even under queue fakes in tests. Checklist gate for 032 is PASS; tasks updated accordingly. Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/34 --- .github/agents/copilot-instructions.md | 2 + .gitignore | 7 +- .../TenantpilotDispatchBackupSchedules.php | 29 + app/Exceptions/InvalidPolicyTypeException.php | 17 + .../Resources/BackupScheduleResource.php | 737 ++++++++++++++++++ .../Pages/CreateBackupSchedule.php | 19 + .../Pages/EditBackupSchedule.php | 18 + .../Pages/ListBackupSchedules.php | 19 + .../BackupScheduleRunsRelationManager.php | 110 +++ app/Jobs/ApplyBackupScheduleRetentionJob.php | 100 +++ app/Jobs/RunBackupScheduleJob.php | 275 +++++++ app/Models/BackupSchedule.php | 34 + app/Models/BackupScheduleRun.php | 48 ++ app/Models/Tenant.php | 10 + ...ackupScheduleRunDispatchedNotification.php | 55 ++ app/Policies/BackupSchedulePolicy.php | 46 ++ app/Providers/AppServiceProvider.php | 5 + app/Rules/SupportedPolicyTypesRule.php | 22 + .../BackupScheduleDispatcher.php | 133 ++++ .../BackupScheduling/PolicyTypeResolver.php | 55 ++ .../BackupScheduling/RunErrorMapper.php | 86 ++ .../BackupScheduling/ScheduleTimeService.php | 88 +++ app/Support/TenantRole.php | 19 + ...5_011014_create_backup_schedules_table.php | 43 + ...1034_create_backup_schedule_runs_table.php | 41 + .../modals/backup-schedule-run-view.blade.php | 48 ++ routes/console.php | 3 + .../checklists/requirements.md | 24 +- .../contracts/backup-scheduling.openapi.yaml | 204 +++++ specs/032-backup-scheduling-mvp/data-model.md | 98 +++ specs/032-backup-scheduling-mvp/plan.md | 136 ++-- specs/032-backup-scheduling-mvp/quickstart.md | 71 ++ specs/032-backup-scheduling-mvp/research.md | 77 ++ specs/032-backup-scheduling-mvp/spec.md | 15 +- specs/032-backup-scheduling-mvp/tasks.md | 137 +++- .../ApplyRetentionJobTest.php | 67 ++ .../BackupScheduleBulkDeleteTest.php | 89 +++ .../BackupScheduleCrudTest.php | 93 +++ .../BackupScheduleRunViewModalTest.php | 53 ++ .../BackupScheduleValidationTest.php | 48 ++ .../DispatchIdempotencyTest.php | 40 + .../RunBackupScheduleJobTest.php | 123 +++ .../BackupScheduling/RunErrorMappingTest.php | 42 + .../RunNowRetryActionsTest.php | 205 +++++ .../ScheduleTimeServiceTest.php | 45 ++ 45 files changed, 3530 insertions(+), 106 deletions(-) create mode 100644 app/Console/Commands/TenantpilotDispatchBackupSchedules.php create mode 100644 app/Exceptions/InvalidPolicyTypeException.php create mode 100644 app/Filament/Resources/BackupScheduleResource.php create mode 100644 app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php create mode 100644 app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php create mode 100644 app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php create mode 100644 app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php create mode 100644 app/Jobs/ApplyBackupScheduleRetentionJob.php create mode 100644 app/Jobs/RunBackupScheduleJob.php create mode 100644 app/Models/BackupSchedule.php create mode 100644 app/Models/BackupScheduleRun.php create mode 100644 app/Notifications/BackupScheduleRunDispatchedNotification.php create mode 100644 app/Policies/BackupSchedulePolicy.php create mode 100644 app/Rules/SupportedPolicyTypesRule.php create mode 100644 app/Services/BackupScheduling/BackupScheduleDispatcher.php create mode 100644 app/Services/BackupScheduling/PolicyTypeResolver.php create mode 100644 app/Services/BackupScheduling/RunErrorMapper.php create mode 100644 app/Services/BackupScheduling/ScheduleTimeService.php create mode 100644 database/migrations/2026_01_05_011014_create_backup_schedules_table.php create mode 100644 database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php create mode 100644 resources/views/filament/modals/backup-schedule-run-view.blade.php create mode 100644 specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml create mode 100644 specs/032-backup-scheduling-mvp/data-model.md create mode 100644 specs/032-backup-scheduling-mvp/quickstart.md create mode 100644 specs/032-backup-scheduling-mvp/research.md create mode 100644 tests/Feature/BackupScheduling/ApplyRetentionJobTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleCrudTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php create mode 100644 tests/Feature/BackupScheduling/BackupScheduleValidationTest.php create mode 100644 tests/Feature/BackupScheduling/DispatchIdempotencyTest.php create mode 100644 tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php create mode 100644 tests/Feature/BackupScheduling/RunErrorMappingTest.php create mode 100644 tests/Feature/BackupScheduling/RunNowRetryActionsTest.php create mode 100644 tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 1a9df86..0fb9ff8 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -5,6 +5,7 @@ # TenantAtlas Development Guidelines ## Active Technologies - PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 (feat/005-bulk-operations) - PostgreSQL (app), SQLite in-memory (tests) (feat/005-bulk-operations) +- PostgreSQL (Sail locally) (feat/032-backup-scheduling-mvp) - PHP 8.4.15 (feat/005-bulk-operations) @@ -24,6 +25,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3 - feat/005-bulk-operations: Added PHP 8.4.15 diff --git a/.gitignore b/.gitignore index 1b59610..766ffe9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ /.zed /auth.json /node_modules +dist/ +build/ +coverage/ /public/build /public/hot /public/storage @@ -22,4 +25,6 @@ Homestead.json Homestead.yaml Thumbs.db -/references \ No newline at end of file +/references +*.tmp +*.swp diff --git a/app/Console/Commands/TenantpilotDispatchBackupSchedules.php b/app/Console/Commands/TenantpilotDispatchBackupSchedules.php new file mode 100644 index 0000000..6c2e12b --- /dev/null +++ b/app/Console/Commands/TenantpilotDispatchBackupSchedules.php @@ -0,0 +1,29 @@ +option('tenant'); + + $result = $dispatcher->dispatchDue($tenantIdentifiers); + + $this->info(sprintf( + 'Scanned %d schedule(s), created %d run(s), skipped %d duplicate run(s).', + $result['scanned_schedules'], + $result['created_runs'], + $result['skipped_runs'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Exceptions/InvalidPolicyTypeException.php b/app/Exceptions/InvalidPolicyTypeException.php new file mode 100644 index 0000000..0c751e4 --- /dev/null +++ b/app/Exceptions/InvalidPolicyTypeException.php @@ -0,0 +1,17 @@ +unknownPolicyTypes = array_values($unknownPolicyTypes); + + parent::__construct('Unknown policy types: '.implode(', ', $this->unknownPolicyTypes)); + } +} diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php new file mode 100644 index 0000000..8ef28ce --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -0,0 +1,737 @@ +user(); + + if (! $user instanceof User) { + return null; + } + + return $user->tenantRole(Tenant::current()); + } + + public static function canViewAny(): bool + { + return static::currentTenantRole() !== null; + } + + public static function canView(Model $record): bool + { + return static::currentTenantRole() !== null; + } + + public static function canCreate(): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canEdit(Model $record): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canDelete(Model $record): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function canDeleteAny(): bool + { + return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + } + + public static function form(Schema $schema): Schema + { + return $schema + ->schema([ + TextInput::make('name') + ->label('Schedule Name') + ->required() + ->maxLength(255), + + Toggle::make('is_enabled') + ->label('Enabled') + ->default(true), + + Select::make('timezone') + ->label('Timezone') + ->options(static::timezoneOptions()) + ->searchable() + ->default('UTC') + ->required(), + + Select::make('frequency') + ->label('Frequency') + ->options([ + 'daily' => 'Daily', + 'weekly' => 'Weekly', + ]) + ->default('daily') + ->required() + ->reactive(), + + TextInput::make('time_of_day') + ->label('Time of day') + ->type('time') + ->required() + ->extraInputAttributes(['step' => 60]), + + CheckboxList::make('days_of_week') + ->label('Days of the week') + ->options(static::dayOfWeekOptions()) + ->columns(2) + ->visible(fn (Get $get): bool => $get('frequency') === 'weekly') + ->required(fn (Get $get): bool => $get('frequency') === 'weekly') + ->rules(['array', 'min:1']), + + CheckboxList::make('policy_types') + ->label('Policy types') + ->options(static::policyTypeOptions()) + ->columns(2) + ->required() + ->helperText('Select the Microsoft Graph policy types that should be included in each run.') + ->rules([ + 'array', + 'min:1', + new SupportedPolicyTypesRule, + ]) + ->columnSpanFull(), + + Toggle::make('include_foundations') + ->label('Include foundation types') + ->default(true), + + TextInput::make('retention_keep_last') + ->label('Retention (keep last N Backup Sets)') + ->type('number') + ->default(30) + ->minValue(1) + ->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('next_run_at', 'asc') + ->columns([ + IconColumn::make('is_enabled') + ->label('Enabled') + ->boolean() + ->alignCenter(), + + TextColumn::make('name') + ->searchable() + ->label('Schedule'), + + TextColumn::make('frequency') + ->label('Frequency') + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + 'daily' => 'Daily', + 'weekly' => 'Weekly', + default => (string) $state, + }) + ->color(fn (?string $state): string => match ($state) { + 'daily' => 'success', + 'weekly' => 'warning', + default => 'gray', + }), + + TextColumn::make('time_of_day') + ->label('Time') + ->formatStateUsing(fn (?string $state): ?string => $state ? trim($state) : null), + + TextColumn::make('timezone') + ->label('Timezone'), + + TextColumn::make('policy_types') + ->label('Policy types') + ->wrap() + ->getStateUsing(function (BackupSchedule $record): string { + $state = $record->policy_types; + + if (is_string($state)) { + $decoded = json_decode($state, true); + + if (is_array($decoded)) { + $state = $decoded; + } + } + + if ($state instanceof \Illuminate\Contracts\Support\Arrayable) { + $state = $state->toArray(); + } + + if (! is_array($state)) { + return 'None'; + } + + $types = array_is_list($state) + ? $state + : array_keys(array_filter($state)); + + $types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')); + + if ($types === []) { + return 'None'; + } + + $labelMap = collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $policy): array => [ + (string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))), + ]) + ->filter(fn (string $label, string $type): bool => $type !== '') + ->all(); + + $labels = array_map( + fn (string $type): string => $labelMap[$type] ?? Str::headline($type), + $types, + ); + + return implode(', ', $labels); + }), + + TextColumn::make('retention_keep_last') + ->label('Retention') + ->suffix(' sets'), + + TextColumn::make('last_run_status') + ->label('Last run status') + ->badge() + ->formatStateUsing(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_RUNNING => 'Running', + BackupScheduleRun::STATUS_SUCCESS => 'Success', + BackupScheduleRun::STATUS_PARTIAL => 'Partial', + BackupScheduleRun::STATUS_FAILED => 'Failed', + BackupScheduleRun::STATUS_CANCELED => 'Canceled', + BackupScheduleRun::STATUS_SKIPPED => 'Skipped', + default => $state ? Str::headline($state) : '—', + }) + ->color(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_SUCCESS => 'success', + BackupScheduleRun::STATUS_PARTIAL => 'warning', + BackupScheduleRun::STATUS_RUNNING => 'primary', + BackupScheduleRun::STATUS_SKIPPED => 'gray', + BackupScheduleRun::STATUS_FAILED, + BackupScheduleRun::STATUS_CANCELED => 'danger', + default => 'gray', + }), + + TextColumn::make('last_run_at') + ->label('Last run') + ->dateTime() + ->sortable(), + + TextColumn::make('next_run_at') + ->label('Next run') + ->dateTime() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('enabled_state') + ->label('Enabled') + ->options([ + 'enabled' => 'Enabled', + 'disabled' => 'Disabled', + ]) + ->query(function (Builder $query, array $data): void { + $value = $data['value'] ?? null; + + if (blank($value)) { + return; + } + + if ($value === 'enabled') { + $query->where('is_enabled', true); + + return; + } + + if ($value === 'disabled') { + $query->where('is_enabled', false); + } + }), + ]) + ->actions([ + ActionGroup::make([ + Action::make('runNow') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (BackupSchedule $record): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + $tenant = Tenant::current(); + $user = auth()->user(); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $scheduledFor = $scheduledFor->addMinute(); + } + + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'run_now', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + + if ($user instanceof User) { + $user->notify(new BackupScheduleRunDispatchedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_schedule_id' => (int) $record->id, + 'backup_schedule_run_id' => (int) $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'run_now', + ])); + } + + Notification::make() + ->title('Run dispatched') + ->body('The backup run has been queued.') + ->success() + ->send(); + }), + Action::make('retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (BackupSchedule $record): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + $tenant = Tenant::current(); + $user = auth()->user(); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $scheduledFor = $scheduledFor->addMinute(); + } + + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'retry', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + + if ($user instanceof User) { + $user->notify(new BackupScheduleRunDispatchedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'backup_schedule_id' => (int) $record->id, + 'backup_schedule_run_id' => (int) $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'retry', + ])); + } + + Notification::make() + ->title('Retry dispatched') + ->body('A new backup run has been queued.') + ->success() + ->send(); + }), + EditAction::make() + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + DeleteAction::make() + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ])->icon('heroicon-o-ellipsis-vertical'), + ]) + ->bulkActions([ + BulkActionGroup::make([ + BulkAction::make('bulk_run_now') + ->label('Run now') + ->icon('heroicon-o-play') + ->color('success') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (Collection $records): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $scheduledFor = $scheduledFor->addMinute(); + } + + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + $createdRunIds[] = (int) $run->id; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_run_now', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + if ($user instanceof User) { + $user->notify(new BackupScheduleRunDispatchedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), + 'backup_schedule_run_ids' => $createdRunIds, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'trigger' => 'bulk_run_now', + ])); + } + + Notification::make() + ->title('Runs dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))) + ->success() + ->send(); + }), + BulkAction::make('bulk_retry') + ->label('Retry') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->action(function (Collection $records): void { + abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + + if ($records->isEmpty()) { + return; + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + $createdRunIds = []; + + /** @var BackupSchedule $record */ + foreach ($records as $record) { + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + for ($i = 0; $i < 5; $i++) { + $exists = BackupScheduleRun::query() + ->where('backup_schedule_id', $record->id) + ->where('scheduled_for', $scheduledFor) + ->exists(); + + if (! $exists) { + break; + } + + $scheduledFor = $scheduledFor->addMinute(); + } + + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + $createdRunIds[] = (int) $run->id; + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'backup_schedule.run_dispatched_manual', + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $record->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'trigger' => 'bulk_retry', + ], + ], + ); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + if ($user instanceof User) { + $user->notify(new BackupScheduleRunDispatchedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), + 'backup_schedule_run_ids' => $createdRunIds, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'trigger' => 'bulk_retry', + ])); + } + + Notification::make() + ->title('Retries dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))) + ->success() + ->send(); + }), + DeleteBulkAction::make('bulk_delete') + ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ]), + ]); + } + + public static function getEloquentQuery(): Builder + { + $tenantId = Tenant::current()->getKey(); + + return parent::getEloquentQuery() + ->where('tenant_id', $tenantId) + ->orderByDesc('is_enabled') + ->orderBy('next_run_at'); + } + + public static function getRelations(): array + { + return [ + BackupScheduleRunsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListBackupSchedules::route('/'), + 'create' => Pages\CreateBackupSchedule::route('/create'), + 'edit' => Pages\EditBackupSchedule::route('/{record}/edit'), + ]; + } + + public static function ensurePolicyTypes(array $data): array + { + $types = array_values((array) ($data['policy_types'] ?? [])); + + try { + app(PolicyTypeResolver::class)->ensureSupported($types); + } catch (InvalidPolicyTypeException $exception) { + throw ValidationException::withMessages([ + 'policy_types' => [sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))], + ]); + } + + $data['policy_types'] = $types; + + return $data; + } + + public static function assignTenant(array $data): array + { + $data['tenant_id'] = Tenant::current()->getKey(); + + return $data; + } + + public static function hydrateNextRun(array $data): array + { + if (! empty($data['time_of_day'])) { + $data['time_of_day'] = static::normalizeTimeOfDay($data['time_of_day']); + } + + $schedule = new BackupSchedule; + $schedule->forceFill([ + 'frequency' => $data['frequency'] ?? 'daily', + 'time_of_day' => $data['time_of_day'] ?? '00:00:00', + 'timezone' => $data['timezone'] ?? 'UTC', + 'days_of_week' => (array) ($data['days_of_week'] ?? []), + ]); + + $nextRun = app(ScheduleTimeService::class)->nextRunFor($schedule); + + $data['next_run_at'] = $nextRun?->toDateTimeString(); + + return $data; + } + + public static function normalizeTimeOfDay(string $time): string + { + if (preg_match('/^\d{2}:\d{2}$/', $time)) { + return $time.':00'; + } + + return $time; + } + + protected static function timezoneOptions(): array + { + $zones = DateTimeZone::listIdentifiers(); + + sort($zones); + + return array_combine($zones, $zones); + } + + protected static function policyTypeOptions(): array + { + return static::policyTypeLabelMap(); + } + + protected static function policyTypeLabels(array $types): array + { + $map = static::policyTypeLabelMap(); + + return array_map(fn (string $type): string => $map[$type] ?? Str::headline($type), $types); + } + + protected static function policyTypeLabelMap(): array + { + return collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $policy) => [ + $policy['type'] => $policy['label'] ?? Str::headline($policy['type']), + ]) + ->all(); + } + + protected static function dayOfWeekOptions(): array + { + return [ + 1 => 'Monday', + 2 => 'Tuesday', + 3 => 'Wednesday', + 4 => 'Thursday', + 5 => 'Friday', + 6 => 'Saturday', + 7 => 'Sunday', + ]; + } +} diff --git a/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php b/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php new file mode 100644 index 0000000..d398e46 --- /dev/null +++ b/app/Filament/Resources/BackupScheduleResource/Pages/CreateBackupSchedule.php @@ -0,0 +1,19 @@ +modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::current()->getKey())->with('backupSet')) + ->defaultSort('scheduled_for', 'desc') + ->columns([ + Tables\Columns\TextColumn::make('scheduled_for') + ->label('Scheduled for') + ->dateTime(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (?string $state): string => match ($state) { + BackupScheduleRun::STATUS_SUCCESS => 'success', + BackupScheduleRun::STATUS_PARTIAL => 'warning', + BackupScheduleRun::STATUS_RUNNING => 'primary', + BackupScheduleRun::STATUS_SKIPPED => 'gray', + BackupScheduleRun::STATUS_FAILED, + BackupScheduleRun::STATUS_CANCELED => 'danger', + default => 'gray', + }), + Tables\Columns\TextColumn::make('duration') + ->label('Duration') + ->getStateUsing(function (BackupScheduleRun $record): string { + if (! $record->started_at || ! $record->finished_at) { + return '—'; + } + + $seconds = max(0, $record->started_at->diffInSeconds($record->finished_at)); + + if ($seconds < 60) { + return $seconds.'s'; + } + + $minutes = intdiv($seconds, 60); + $rem = $seconds % 60; + + return sprintf('%dm %ds', $minutes, $rem); + }), + Tables\Columns\TextColumn::make('counts') + ->label('Counts') + ->getStateUsing(function (BackupScheduleRun $record): string { + $summary = is_array($record->summary) ? $record->summary : []; + + $total = (int) ($summary['policies_total'] ?? 0); + $backedUp = (int) ($summary['policies_backed_up'] ?? 0); + $errors = (int) ($summary['errors_count'] ?? 0); + + if ($total === 0 && $backedUp === 0 && $errors === 0) { + return '—'; + } + + return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors); + }), + Tables\Columns\TextColumn::make('error_code') + ->label('Error') + ->badge() + ->default('—'), + Tables\Columns\TextColumn::make('error_message') + ->label('Message') + ->default('—') + ->limit(80) + ->wrap(), + Tables\Columns\TextColumn::make('backup_set_id') + ->label('Backup set') + ->default('—') + ->url(function (BackupScheduleRun $record): ?string { + if (! $record->backup_set_id) { + return null; + } + + return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current()); + }) + ->openUrlInNewTab(true), + ]) + ->filters([]) + ->headerActions([]) + ->actions([ + Actions\Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->modalHeading('View backup schedule run') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(function (BackupScheduleRun $record): View { + return view('filament.modals.backup-schedule-run-view', [ + 'run' => $record, + ]); + }), + ]) + ->bulkActions([]); + } +} diff --git a/app/Jobs/ApplyBackupScheduleRetentionJob.php b/app/Jobs/ApplyBackupScheduleRetentionJob.php new file mode 100644 index 0000000..09f98bf --- /dev/null +++ b/app/Jobs/ApplyBackupScheduleRetentionJob.php @@ -0,0 +1,100 @@ +with('tenant') + ->find($this->backupScheduleId); + + if (! $schedule || ! $schedule->tenant) { + return; + } + + $keepLast = (int) ($schedule->retention_keep_last ?? 30); + + if ($keepLast < 1) { + $keepLast = 1; + } + + /** @var Collection $keepBackupSetIds */ + $keepBackupSetIds = BackupScheduleRun::query() + ->where('backup_schedule_id', $schedule->id) + ->whereNotNull('backup_set_id') + ->orderByDesc('scheduled_for') + ->limit($keepLast) + ->pluck('backup_set_id') + ->filter() + ->values(); + + /** @var Collection $deleteBackupSetIds */ + $deleteBackupSetIds = BackupScheduleRun::query() + ->where('backup_schedule_id', $schedule->id) + ->whereNotNull('backup_set_id') + ->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all())) + ->pluck('backup_set_id') + ->filter() + ->unique() + ->values(); + + if ($deleteBackupSetIds->isEmpty()) { + $auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.retention_applied', + resourceType: 'backup_schedule', + resourceId: (string) $schedule->id, + status: 'success', + context: [ + 'metadata' => [ + 'keep_last' => $keepLast, + 'deleted_backup_sets' => 0, + ], + ], + ); + + return; + } + + $deletedCount = 0; + + BackupSet::query() + ->where('tenant_id', $schedule->tenant_id) + ->whereIn('id', $deleteBackupSetIds->all()) + ->whereNull('deleted_at') + ->chunkById(200, function (Collection $sets) use (&$deletedCount): void { + foreach ($sets as $set) { + $set->delete(); + $deletedCount++; + } + }); + + $auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.retention_applied', + resourceType: 'backup_schedule', + resourceId: (string) $schedule->id, + status: 'success', + context: [ + 'metadata' => [ + 'keep_last' => $keepLast, + 'deleted_backup_sets' => $deletedCount, + ], + ], + ); + } +} diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php new file mode 100644 index 0000000..59a3524 --- /dev/null +++ b/app/Jobs/RunBackupScheduleJob.php @@ -0,0 +1,275 @@ +with(['schedule', 'tenant']) + ->find($this->backupScheduleRunId); + + if (! $run) { + return; + } + + $schedule = $run->schedule; + + if (! $schedule instanceof BackupSchedule) { + $run->update([ + 'status' => BackupScheduleRun::STATUS_FAILED, + 'error_code' => RunErrorMapper::ERROR_UNKNOWN, + 'error_message' => 'Schedule not found.', + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return; + } + + $tenant = $run->tenant; + + if (! $tenant) { + $run->update([ + 'status' => BackupScheduleRun::STATUS_FAILED, + 'error_code' => RunErrorMapper::ERROR_UNKNOWN, + 'error_message' => 'Tenant not found.', + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + return; + } + + $lock = Cache::lock("backup_schedule:{$schedule->id}", 900); + + if (! $lock->get()) { + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorCode: 'CONCURRENT_RUN', + errorMessage: 'Another run is already in progress for this schedule.', + summary: ['reason' => 'concurrent_run'], + scheduleTimeService: $scheduleTimeService, + ); + + return; + } + + try { + $nowUtc = CarbonImmutable::now('UTC'); + + $run->forceFill([ + 'started_at' => $run->started_at ?? $nowUtc, + 'status' => BackupScheduleRun::STATUS_RUNNING, + ])->save(); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_started', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $run->scheduled_for?->toDateTimeString(), + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success' + ); + + $runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? [])); + $validTypes = $runtime['valid']; + $unknownTypes = $runtime['unknown']; + + if (empty($validTypes)) { + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_SKIPPED, + errorCode: 'UNKNOWN_POLICY_TYPE', + errorMessage: 'All configured policy types are unknown.', + summary: [ + 'unknown_policy_types' => $unknownTypes, + ], + scheduleTimeService: $scheduleTimeService, + ); + + return; + } + + $supported = array_values(array_filter( + config('tenantpilot.supported_policy_types', []), + fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true), + )); + + $syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported); + + $policyIds = $syncReport['synced'] ?? []; + $syncFailures = $syncReport['failures'] ?? []; + + $backupSet = $backupService->createBackupSet( + tenant: $tenant, + policyIds: $policyIds, + actorEmail: null, + actorName: null, + name: 'Scheduled backup: '.$schedule->name, + includeAssignments: false, + includeScopeTags: false, + includeFoundations: (bool) ($schedule->include_foundations ?? false), + ); + + $status = match ($backupSet->status) { + 'completed' => BackupScheduleRun::STATUS_SUCCESS, + 'partial' => BackupScheduleRun::STATUS_PARTIAL, + 'failed' => BackupScheduleRun::STATUS_FAILED, + default => BackupScheduleRun::STATUS_SUCCESS, + }; + + $errorCode = null; + $errorMessage = null; + + $summary = [ + 'policies_total' => count($policyIds), + 'policies_backed_up' => (int) ($backupSet->item_count ?? 0), + 'sync_failures' => $syncFailures, + ]; + + if (! empty($unknownTypes)) { + $status = BackupScheduleRun::STATUS_PARTIAL; + $errorCode = 'UNKNOWN_POLICY_TYPE'; + $errorMessage = 'Some configured policy types are unknown and were skipped.'; + $summary['unknown_policy_types'] = $unknownTypes; + } + + $this->finishRun( + run: $run, + schedule: $schedule, + status: $status, + errorCode: $errorCode, + errorMessage: $errorMessage, + summary: $summary, + scheduleTimeService: $scheduleTimeService, + backupSetId: (string) $backupSet->id, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_finished', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'status' => $status, + 'error_code' => $errorCode, + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial' + ); + } catch (\Throwable $throwable) { + $attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1; + $mapped = $errorMapper->map($throwable, $attempt, $this->tries); + + if ($mapped['shouldRetry']) { + $this->release($mapped['delay']); + + return; + } + + $this->finishRun( + run: $run, + schedule: $schedule, + status: BackupScheduleRun::STATUS_FAILED, + errorCode: $mapped['error_code'], + errorMessage: $mapped['error_message'], + summary: [ + 'exception' => get_class($throwable), + 'attempt' => $attempt, + ], + scheduleTimeService: $scheduleTimeService, + ); + + $auditLogger->log( + tenant: $tenant, + action: 'backup_schedule.run_failed', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'error_code' => $mapped['error_code'], + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'failed' + ); + } finally { + optional($lock)->release(); + } + } + + private function finishRun( + BackupScheduleRun $run, + BackupSchedule $schedule, + string $status, + ?string $errorCode, + ?string $errorMessage, + array $summary, + ScheduleTimeService $scheduleTimeService, + ?string $backupSetId = null, + ): void { + $nowUtc = CarbonImmutable::now('UTC'); + + $run->forceFill([ + 'status' => $status, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'summary' => Arr::wrap($summary), + 'finished_at' => $nowUtc, + 'backup_set_id' => $backupSetId, + ])->save(); + + $schedule->forceFill([ + 'last_run_at' => $nowUtc, + 'last_run_status' => $status, + 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { + Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); + } + } +} diff --git a/app/Models/BackupSchedule.php b/app/Models/BackupSchedule.php new file mode 100644 index 0000000..66e4e21 --- /dev/null +++ b/app/Models/BackupSchedule.php @@ -0,0 +1,34 @@ + 'boolean', + 'include_foundations' => 'boolean', + 'days_of_week' => 'array', + 'policy_types' => 'array', + 'last_run_at' => 'datetime', + 'next_run_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function runs(): HasMany + { + return $this->hasMany(BackupScheduleRun::class); + } +} diff --git a/app/Models/BackupScheduleRun.php b/app/Models/BackupScheduleRun.php new file mode 100644 index 0000000..76feb90 --- /dev/null +++ b/app/Models/BackupScheduleRun.php @@ -0,0 +1,48 @@ + 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + 'summary' => 'array', + ]; + + public function schedule(): BelongsTo + { + return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id'); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function backupSet(): BelongsTo + { + return $this->belongsTo(BackupSet::class); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 29c5af5..1294555 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -175,6 +175,16 @@ public function backupSets(): HasMany return $this->hasMany(BackupSet::class); } + public function backupSchedules(): HasMany + { + return $this->hasMany(BackupSchedule::class); + } + + public function backupScheduleRuns(): HasMany + { + return $this->hasMany(BackupScheduleRun::class); + } + public function policyVersions(): HasMany { return $this->hasMany(PolicyVersion::class); diff --git a/app/Notifications/BackupScheduleRunDispatchedNotification.php b/app/Notifications/BackupScheduleRunDispatchedNotification.php new file mode 100644 index 0000000..9690097 --- /dev/null +++ b/app/Notifications/BackupScheduleRunDispatchedNotification.php @@ -0,0 +1,55 @@ +, + * backup_schedule_run_ids?:array + * } $metadata + */ + public function __construct(public array $metadata) {} + + /** + * @return array + */ + public function via(object $notifiable): array + { + return ['database']; + } + + /** + * @return array + */ + public function toDatabase(object $notifiable): array + { + $trigger = (string) ($this->metadata['trigger'] ?? 'run_now'); + + $title = match ($trigger) { + 'retry' => 'Retry dispatched', + 'bulk_retry' => 'Retries dispatched', + 'bulk_run_now' => 'Runs dispatched', + default => 'Run dispatched', + }; + + $body = match ($trigger) { + 'bulk_retry', 'bulk_run_now' => 'Backup runs have been queued.', + default => 'A backup run has been queued.', + }; + + return [ + 'title' => $title, + 'body' => $body, + 'metadata' => $this->metadata, + ]; + } +} diff --git a/app/Policies/BackupSchedulePolicy.php b/app/Policies/BackupSchedulePolicy.php new file mode 100644 index 0000000..4fb4d78 --- /dev/null +++ b/app/Policies/BackupSchedulePolicy.php @@ -0,0 +1,46 @@ +tenantRole($tenant); + } + + public function viewAny(User $user): bool + { + return $this->resolveRole($user) !== null; + } + + public function view(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user) !== null; + } + + public function create(User $user): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } + + public function update(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } + + public function delete(User $user, BackupSchedule $schedule): bool + { + return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9da238b..248c52c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Models\BackupSchedule; use App\Models\Tenant; use App\Models\User; use App\Models\UserTenantPreference; +use App\Policies\BackupSchedulePolicy; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\NullGraphClient; @@ -23,6 +25,7 @@ use App\Services\Intune\WindowsUpdateRingNormalizer; use Filament\Events\TenantSet; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; @@ -102,5 +105,7 @@ public function boot(): void ], ); }); + + Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); } } diff --git a/app/Rules/SupportedPolicyTypesRule.php b/app/Rules/SupportedPolicyTypesRule.php new file mode 100644 index 0000000..ca73105 --- /dev/null +++ b/app/Rules/SupportedPolicyTypesRule.php @@ -0,0 +1,22 @@ +ensureSupported($types); + } catch (InvalidPolicyTypeException $exception) { + $fail(sprintf('Unknown policy types: %s.', implode(', ', $exception->unknownPolicyTypes))); + } + } +} diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php new file mode 100644 index 0000000..f0bd207 --- /dev/null +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -0,0 +1,133 @@ +where('is_enabled', true) + ->whereHas('tenant', fn ($query) => $query->where('status', 'active')) + ->with('tenant'); + + if (is_array($tenantIdentifiers) && ! empty($tenantIdentifiers)) { + $schedulesQuery->whereIn('tenant_id', $this->resolveTenantIds($tenantIdentifiers)); + } + + $createdRuns = 0; + $skippedRuns = 0; + $scannedSchedules = 0; + + foreach ($schedulesQuery->cursor() as $schedule) { + $scannedSchedules++; + + $slot = $this->scheduleTimeService->nextRunFor($schedule, $nowUtc->subMinute()); + + if ($slot === null) { + $schedule->forceFill(['next_run_at' => null])->saveQuietly(); + + continue; + } + + if ($slot->greaterThan($nowUtc)) { + if (! $schedule->next_run_at || ! $schedule->next_run_at->equalTo($slot)) { + $schedule->forceFill(['next_run_at' => $slot])->saveQuietly(); + } + + continue; + } + + $run = null; + + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $schedule->tenant_id, + 'scheduled_for' => $slot->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + } catch (QueryException $exception) { + // Idempotency: unique (backup_schedule_id, scheduled_for) + $skippedRuns++; + + continue; + } + + $createdRuns++; + + $this->auditLogger->log( + tenant: $schedule->tenant, + action: 'backup_schedule.run_dispatched', + context: [ + 'metadata' => [ + 'backup_schedule_id' => $schedule->id, + 'backup_schedule_run_id' => $run->id, + 'scheduled_for' => $slot->toDateTimeString(), + ], + ], + resourceType: 'backup_schedule_run', + resourceId: (string) $run->id, + status: 'success' + ); + + $schedule->forceFill([ + 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + + Bus::dispatch(new RunBackupScheduleJob($run->id)); + } + + return [ + 'created_runs' => $createdRuns, + 'skipped_runs' => $skippedRuns, + 'scanned_schedules' => $scannedSchedules, + ]; + } + + /** + * @param array $tenantIdentifiers + * @return array + */ + private function resolveTenantIds(array $tenantIdentifiers): array + { + $tenantIds = []; + + foreach ($tenantIdentifiers as $identifier) { + $tenant = Tenant::query() + ->where('status', 'active') + ->forTenant($identifier) + ->first(); + + if ($tenant) { + $tenantIds[] = $tenant->id; + } + } + + return array_values(array_unique($tenantIds)); + } +} diff --git a/app/Services/BackupScheduling/PolicyTypeResolver.php b/app/Services/BackupScheduling/PolicyTypeResolver.php new file mode 100644 index 0000000..6c11c98 --- /dev/null +++ b/app/Services/BackupScheduling/PolicyTypeResolver.php @@ -0,0 +1,55 @@ +findUnknown($types); + + if (! empty($unknown)) { + throw new InvalidPolicyTypeException($unknown); + } + } + + public function filterRuntime(array $types): array + { + $valid = $this->filter($types); + + return array_values($valid); + } + + public function resolveRuntime(array $types): array + { + $valid = $this->filter($types); + $unknown = $this->findUnknown($types); + + return [ + 'valid' => array_values($valid), + 'unknown' => array_values($unknown), + ]; + } + + protected function filter(array $types): array + { + $supported = $this->supportedPolicyTypes(); + + return array_values(array_intersect($types, $supported)); + } + + protected function findUnknown(array $types): array + { + $supported = $this->supportedPolicyTypes(); + + return array_values(array_diff($types, $supported)); + } +} diff --git a/app/Services/BackupScheduling/RunErrorMapper.php b/app/Services/BackupScheduling/RunErrorMapper.php new file mode 100644 index 0000000..613c023 --- /dev/null +++ b/app/Services/BackupScheduling/RunErrorMapper.php @@ -0,0 +1,86 @@ +status; + + if ($status === 401) { + return $this->final(self::ERROR_TOKEN_EXPIRED, $throwable->getMessage()); + } + + if ($status === 403) { + return $this->final(self::ERROR_PERMISSION_MISSING, $throwable->getMessage()); + } + + if ($status === 429) { + return $this->retry(self::ERROR_GRAPH_THROTTLE, $throwable->getMessage(), $attempt, $maxAttempts); + } + + if ($status === 503) { + return $this->retry(self::ERROR_GRAPH_UNAVAILABLE, $throwable->getMessage(), $attempt, $maxAttempts); + } + + return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts); + } + + return $this->retry(self::ERROR_UNKNOWN, $throwable->getMessage(), $attempt, $maxAttempts); + } + + /** + * @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string} + */ + private function retry(string $code, string $message, int $attempt, int $maxAttempts): array + { + if ($attempt >= $maxAttempts) { + return $this->final($code, $message); + } + + $delays = [60, 300, 900]; + $delay = $delays[min($attempt - 1, count($delays) - 1)]; + + return [ + 'shouldRetry' => true, + 'delay' => $delay, + 'error_code' => $code, + 'error_message' => $message, + 'final_status' => 'failed', + ]; + } + + /** + * @return array{shouldRetry: bool, delay: int, error_code: string, error_message: string, final_status: string} + */ + private function final(string $code, string $message): array + { + return [ + 'shouldRetry' => false, + 'delay' => 0, + 'error_code' => $code, + 'error_message' => $message, + 'final_status' => 'failed', + ]; + } +} diff --git a/app/Services/BackupScheduling/ScheduleTimeService.php b/app/Services/BackupScheduling/ScheduleTimeService.php new file mode 100644 index 0000000..331af54 --- /dev/null +++ b/app/Services/BackupScheduling/ScheduleTimeService.php @@ -0,0 +1,88 @@ +timezone; + $cursor = $after?->copy()->timezone($timezone) ?? CarbonImmutable::now($timezone); + + if ($schedule->frequency === 'weekly') { + return $this->nextWeeklyRun($schedule, $cursor); + } + + return $this->nextDailyRun($schedule, $cursor); + } + + protected function nextDailyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable + { + $time = $schedule->time_of_day; + $attempts = 0; + + if ($cursor->format('H:i:s') >= $time) { + $cursor = $cursor->addDay(); + } + + while ($attempts++ < 14) { + $candidate = $this->buildLocalSlot($schedule, $cursor); + + if ($candidate) { + return $candidate; + } + + $cursor = $cursor->addDay(); + } + + return null; + } + + protected function nextWeeklyRun(BackupSchedule $schedule, CarbonImmutable $cursor): ?CarbonImmutable + { + $allowed = $schedule->days_of_week ?? []; + $allowed = array_filter($allowed, fn ($day) => is_numeric($day) && $day >= 1 && $day <= 7); + $allowed = array_values($allowed); + + if (empty($allowed)) { + return null; + } + + $attempts = 0; + + while ($attempts++ < 21) { + $dayOfWeek = $cursor->dayOfWeekIso; + + if (in_array($dayOfWeek, $allowed, true)) { + $candidate = $this->buildLocalSlot($schedule, $cursor); + + $cursorUtc = $cursor->copy()->timezone('UTC'); + + if ($candidate && $candidate->greaterThan($cursorUtc)) { + return $candidate; + } + } + + $cursor = $cursor->addDay()->startOfDay(); + } + + return null; + } + + protected function buildLocalSlot(BackupSchedule $schedule, CarbonImmutable $date): ?CarbonImmutable + { + $timezone = $schedule->timezone; + $time = $schedule->time_of_day; + $datePart = $date->format('Y-m-d'); + $candidate = CarbonImmutable::createFromFormat('Y-m-d H:i:s', "{$datePart} {$time}", $timezone); + + if (! $candidate || $candidate->format('H:i:s') !== $time) { + return null; + } + + return $candidate->startOfMinute()->timezone('UTC'); + } +} diff --git a/app/Support/TenantRole.php b/app/Support/TenantRole.php index db82a29..38c8a00 100644 --- a/app/Support/TenantRole.php +++ b/app/Support/TenantRole.php @@ -18,4 +18,23 @@ public function canSync(): bool self::Readonly => false, }; } + + public function canManageBackupSchedules(): bool + { + return match ($this) { + self::Owner, + self::Manager => true, + default => false, + }; + } + + public function canRunBackupSchedules(): bool + { + return match ($this) { + self::Owner, + self::Manager, + self::Operator => true, + self::Readonly => false, + }; + } } diff --git a/database/migrations/2026_01_05_011014_create_backup_schedules_table.php b/database/migrations/2026_01_05_011014_create_backup_schedules_table.php new file mode 100644 index 0000000..08bdf6e --- /dev/null +++ b/database/migrations/2026_01_05_011014_create_backup_schedules_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->string('name'); + $table->boolean('is_enabled')->default(true); + $table->string('timezone')->default('UTC'); + $table->enum('frequency', ['daily', 'weekly']); + $table->time('time_of_day'); + $table->json('days_of_week')->nullable(); + $table->json('policy_types'); + $table->boolean('include_foundations')->default(true); + $table->integer('retention_keep_last')->default(30); + $table->dateTime('last_run_at')->nullable(); + $table->string('last_run_status')->nullable(); + $table->dateTime('next_run_at')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'is_enabled']); + $table->index('next_run_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_schedules'); + } +}; diff --git a/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php b/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php new file mode 100644 index 0000000..edc1021 --- /dev/null +++ b/database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete(); + $table->dateTime('scheduled_for'); + $table->dateTime('started_at')->nullable(); + $table->dateTime('finished_at')->nullable(); + $table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']); + $table->json('summary')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['backup_schedule_id', 'scheduled_for']); + $table->index(['backup_schedule_id', 'scheduled_for']); + $table->index(['tenant_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('backup_schedule_runs'); + } +}; diff --git a/resources/views/filament/modals/backup-schedule-run-view.blade.php b/resources/views/filament/modals/backup-schedule-run-view.blade.php new file mode 100644 index 0000000..e1fa38c --- /dev/null +++ b/resources/views/filament/modals/backup-schedule-run-view.blade.php @@ -0,0 +1,48 @@ + +
+
+
+
Scheduled for
+
{{ optional($run->scheduled_for)->toDateTimeString() ?? '—' }}
+
+
+
Status
+
{{ $run->status ?? '—' }}
+
+
+ +
+
+
Started at
+
{{ optional($run->started_at)->toDateTimeString() ?? '—' }}
+
+
+
Finished at
+
{{ optional($run->finished_at)->toDateTimeString() ?? '—' }}
+
+
+ +
+
+
Error code
+
{{ $run->error_code ?: '—' }}
+
+
+
Backup set
+
{{ $run->backup_set_id ?: '—' }}
+
+
+ +
+
Error message
+
{{ $run->error_message ?: '—' }}
+
+ +
+
Summary
+
+
{{ json_encode($run->summary ?? [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+
+
+
diff --git a/routes/console.php b/routes/console.php index 3c9adf1..f2ce44a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('tenantpilot:schedules:dispatch')->everyMinute(); diff --git a/specs/032-backup-scheduling-mvp/checklists/requirements.md b/specs/032-backup-scheduling-mvp/checklists/requirements.md index db99631..82de8d6 100644 --- a/specs/032-backup-scheduling-mvp/checklists/requirements.md +++ b/specs/032-backup-scheduling-mvp/checklists/requirements.md @@ -1,13 +1,13 @@ # Requirements Checklist (032) - -- [ ] Tenant-scoped tables use `tenant_id` consistently. -- [ ] 1 Run = 1 BackupSet (no rolling reuse in MVP). -- [ ] Dispatcher is idempotent (unique schedule_id + scheduled_for). -- [ ] Concurrency lock prevents parallel runs per schedule. -- [ ] Run stores status + summary + error_code/error_message. -- [ ] UI shows schedule list + run history + link to backup set. -- [ ] Run now + Retry are permission-gated and write DB notifications. -- [ ] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). -- [ ] Retry/backoff policy implemented (no retry for 401/403). -- [ ] Retention keeps last N and soft-deletes older backup sets. -- [ ] Tests cover due-calculation, idempotency, job success/failure, retention. +- [X] Tenant-scoped tables use `tenant_id` consistently. (Data model section in spec.md documents tenant_id on `backup_schedules` and `backup_schedule_runs`.) +- [X] 1 Run = 1 BackupSet (no rolling reuse in MVP). (Definitions + Goals in spec.md state the MVP semantics explicitly.) +- [X] Dispatcher is idempotent (unique schedule_id + scheduled_for). (Requirements FR-002 + FR-007 + plan's idempotent dispatch constraint specify unique slots.) +- [X] Concurrency lock prevents parallel runs per schedule. (FR-008 and plan note per-schedule concurrency lock; tasks T024/Run job mention locking.) +- [X] Run stores status + summary + error_code/error_message. (FR-004 and data model show these fields exist in `backup_schedule_runs`.) +- [X] UI shows schedule list + run history + link to backup set. (UX-001/UX-002 in spec, tasks T014 / relation managers + UI doc.) +- [X] Run now + Retry are permission-gated and write DB notifications. (SEC-002 + tasks T031-T034 describe Filament actions + notifications.) +- [X] Audit logs are written for dispatcher, runs, and retention (tenant-scoped; no secrets). (SEC-003 plus tasks T026/T033/T034 mention audit logging.) +- [X] Retry/backoff policy implemented (no retry for 401/403). (NFR-003 and tasks T025 mention retry/backoff rules.) +- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.) +- [X] Tests cover due-calculation, idempotency, job success/failure, retention. (Tasks T011-T037 include Pest tests for due calculation, idempotency, job outcomes, and retention.) +- [X] Retention keeps last N and soft-deletes older backup sets. (FR-007 + tasks T033/T034 describe retention job & soft delete.) diff --git a/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml b/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml new file mode 100644 index 0000000..982f465 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/contracts/backup-scheduling.openapi.yaml @@ -0,0 +1,204 @@ +openapi: 3.0.3 +info: + title: TenantPilot Backup Scheduling (Spec 032) + version: "0.1" + description: | + Conceptual contract for Backup Scheduling MVP. TenantPilot uses Filament/Livewire; + these endpoints describe behavior for review/testing and future API alignment. +servers: + - url: https://{host} + variables: + host: + default: example.local + +paths: + /tenants/{tenantId}/backup-schedules: + get: + summary: List backup schedules for a tenant + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BackupSchedule' + post: + summary: Create a backup schedule + parameters: + - $ref: '#/components/parameters/TenantId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleCreate' + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/BackupSchedule' + '422': + description: Validation error (e.g. unknown policy_types) + + /tenants/{tenantId}/backup-schedules/{scheduleId}: + patch: + summary: Update a backup schedule + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleUpdate' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/BackupSchedule' + '422': + description: Validation error + delete: + summary: Delete (or disable) a schedule + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '204': + description: Deleted + + /tenants/{tenantId}/backup-schedules/{scheduleId}/run-now: + post: + summary: Trigger a run immediately + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '202': + description: Accepted (run created and job dispatched) + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleRun' + + /tenants/{tenantId}/backup-schedules/{scheduleId}/retry: + post: + summary: Create a new run as retry + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '202': + description: Accepted + content: + application/json: + schema: + $ref: '#/components/schemas/BackupScheduleRun' + + /tenants/{tenantId}/backup-schedules/{scheduleId}/runs: + get: + summary: List runs for a schedule + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/ScheduleId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BackupScheduleRun' + +components: + parameters: + TenantId: + name: tenantId + in: path + required: true + schema: + type: integer + ScheduleId: + name: scheduleId + in: path + required: true + schema: + type: integer + + schemas: + BackupSchedule: + type: object + required: [id, tenant_id, name, is_enabled, timezone, frequency, time_of_day, policy_types, retention_keep_last] + properties: + id: { type: integer } + tenant_id: { type: integer } + name: { type: string } + is_enabled: { type: boolean } + timezone: { type: string, example: "Europe/Berlin" } + frequency: { type: string, enum: [daily, weekly] } + time_of_day: { type: string, example: "02:00:00" } + days_of_week: + type: array + nullable: true + items: { type: integer, minimum: 1, maximum: 7 } + policy_types: + type: array + items: { type: string } + description: Must be keys from config('tenantpilot.supported_policy_types'). + include_foundations: { type: boolean } + retention_keep_last: { type: integer, minimum: 1 } + last_run_at: { type: string, format: date-time, nullable: true } + last_run_status: { type: string, nullable: true } + next_run_at: { type: string, format: date-time, nullable: true } + + BackupScheduleCreate: + allOf: + - $ref: '#/components/schemas/BackupScheduleUpdate' + - type: object + required: [name, timezone, frequency, time_of_day, policy_types] + + BackupScheduleUpdate: + type: object + properties: + name: { type: string } + is_enabled: { type: boolean } + timezone: { type: string } + frequency: { type: string, enum: [daily, weekly] } + time_of_day: { type: string } + days_of_week: + type: array + nullable: true + items: { type: integer, minimum: 1, maximum: 7 } + policy_types: + type: array + items: { type: string } + include_foundations: { type: boolean } + retention_keep_last: { type: integer, minimum: 1 } + + BackupScheduleRun: + type: object + required: [id, backup_schedule_id, tenant_id, scheduled_for, status] + properties: + id: { type: integer } + backup_schedule_id: { type: integer } + tenant_id: { type: integer } + scheduled_for: { type: string, format: date-time } + started_at: { type: string, format: date-time, nullable: true } + finished_at: { type: string, format: date-time, nullable: true } + status: { type: string, enum: [running, success, partial, failed, canceled, skipped] } + summary: + type: object + additionalProperties: true + error_code: { type: string, nullable: true } + error_message: { type: string, nullable: true } + backup_set_id: { type: integer, nullable: true } diff --git a/specs/032-backup-scheduling-mvp/data-model.md b/specs/032-backup-scheduling-mvp/data-model.md new file mode 100644 index 0000000..6231f73 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/data-model.md @@ -0,0 +1,98 @@ +# Data Model: Backup Scheduling MVP (032) + +**Date**: 2026-01-05 + +This document describes the entities, relationships, validation rules, and state transitions derived from the feature spec. + +## Entities + +### 1) BackupSchedule (`backup_schedules`) + +**Purpose**: Defines a tenant-scoped recurring backup plan. + +**Fields** +- `id` (bigint, PK) +- `tenant_id` (FK → `tenants.id`, required) +- `name` (string, required) +- `is_enabled` (bool, default true) +- `timezone` (string, required; default `UTC`) +- `frequency` (enum: `daily|weekly`, required) +- `time_of_day` (time, required) +- `days_of_week` (json, nullable; required when `frequency=weekly`) + - array in range 1..7 (Mon..Sun) +- `policy_types` (jsonb, required) + - array; keys MUST exist in `config('tenantpilot.supported_policy_types')` +- `include_foundations` (bool, default true) +- `retention_keep_last` (int, default 30) +- `last_run_at` (datetime, nullable) +- `last_run_status` (string, nullable) +- `next_run_at` (datetime, nullable) +- timestamps + +**Indexes** +- `(tenant_id, is_enabled)` +- optional `(next_run_at)` + +**Validation Rules (MVP)** +- `tenant_id`: required, exists +- `name`: required, max length (e.g. 255) +- `timezone`: required, valid IANA tz +- `frequency`: required, in `[daily, weekly]` +- `time_of_day`: required +- `days_of_week`: required if weekly; values 1..7; unique values +- `policy_types`: required, array, min 1; all values in supported types config +- `retention_keep_last`: required, int, min 1 + +**State** +- Enabled/disabled (`is_enabled`) + +--- + +### 2) BackupScheduleRun (`backup_schedule_runs`) + +**Purpose**: Represents one execution attempt of a schedule. + +**Fields** +- `id` (bigint, PK) +- `backup_schedule_id` (FK → `backup_schedules.id`, required) +- `tenant_id` (FK → `tenants.id`, required; denormalized) +- `scheduled_for` (datetime, required; UTC minute-slot) +- `started_at` (datetime, nullable) +- `finished_at` (datetime, nullable) +- `status` (enum: `running|success|partial|failed|canceled|skipped`, required) +- `summary` (jsonb, required) + - suggested keys: + - `policies_total` (int) + - `policies_backed_up` (int) + - `errors_count` (int) + - `type_breakdown` (object) + - `warnings` (array) + - `unknown_policy_types` (array) +- `error_code` (string, nullable) +- `error_message` (text, nullable) +- `backup_set_id` (FK → `backup_sets.id`, nullable) +- timestamps + +**Indexes** +- `(backup_schedule_id, scheduled_for)` +- `(tenant_id, created_at)` +- unique `(backup_schedule_id, scheduled_for)` (idempotency) + +**State transitions** +- `running` → `success|partial|failed|skipped|canceled` + +--- + +## Relationships + +- Tenant `hasMany` BackupSchedule +- BackupSchedule `belongsTo` Tenant +- BackupSchedule `hasMany` BackupScheduleRun +- BackupScheduleRun `belongsTo` BackupSchedule +- BackupScheduleRun `belongsTo` Tenant +- BackupScheduleRun `belongsTo` BackupSet (nullable) + +## Notes + +- `BackupSet` and `BackupItem` already support soft deletes in this repo; retention can soft-delete old backup sets. +- Unknown policy types are prevented at save-time, but runs defensively re-check to handle legacy DB data. diff --git a/specs/032-backup-scheduling-mvp/plan.md b/specs/032-backup-scheduling-mvp/plan.md index 468d288..ad64e4d 100644 --- a/specs/032-backup-scheduling-mvp/plan.md +++ b/specs/032-backup-scheduling-mvp/plan.md @@ -1,67 +1,85 @@ -# Plan: Backup Scheduling MVP (032) +# Implementation Plan: Backup Scheduling MVP (032) -**Date**: 2026-01-05 -**Input**: spec.md +**Branch**: `feat/032-backup-scheduling-mvp` | **Date**: 2026-01-05 | **Spec**: specs/032-backup-scheduling-mvp/spec.md +**Input**: Feature specification from `specs/032-backup-scheduling-mvp/spec.md` -## Architecture / Reuse -- Reuse existing services: - - `PolicySyncService::syncPoliciesWithReport()` for selected policy types - - `BackupService::createBackupSet()` to create immutable snapshots + items (include_foundations supported) -- Store selection as `policy_types` (config keys), not free-form categories. -- Use tenant scoping (`tenant_id`) consistent with existing tables (`backup_sets`, `backup_items`). +## Summary -## Scheduling Mechanism -- Add Artisan command: `tenantpilot:schedules:dispatch`. -- Scheduler integration (Laravel 12): schedule the command every minute via `routes/console.php` + ops configuration (Dokploy cron `schedule:run` or long-running `schedule:work`). -- Dispatcher algorithm: - 1) load enabled schedules - 2) compute whether due for the current minute in schedule timezone - 3) create run with `scheduled_for` slot (minute precision) using DB unique constraint - 4) dispatch `RunBackupScheduleJob(schedule_id, run_id)` -- Concurrency: - - Cache lock per schedule (`lock:backup_schedule:{id}`) plus DB unique slot constraint for idempotency. - - If lock is held: mark run as `skipped` with a clear error_code (no parallel execution). +Implement tenant-scoped backup schedules that dispatch idempotent runs every minute via Laravel scheduler and queue workers. Each run syncs selected policy types from Graph into the local DB (via existing `PolicySyncService`) and creates an immutable `BackupSet` snapshot (via existing `BackupService`), with strict audit logging, fail-safe handling for unknown policy types, retention (keep last N), and Filament UI for managing schedules and viewing run history. -## Run Execution -- `RunBackupScheduleJob`: - 1) load schedule + tenant - 2) preflight: tenant active; Graph/auth errors mapped to error_code - 3) sync policies for selected types (collect report) - 4) select policy IDs from local DB for those types (exclude ignored) - 5) create backup set: - - name: `{schedule_name} - {Y-m-d H:i}` - - includeFoundations: schedule flag - 6) set run status: - - success if backup_set.status == completed - - partial if backup_set.status == partial OR sync had failures but backup succeeded - - failed if nothing backed up / hard error - 7) update schedule last_run_* and compute/persist next_run_at - 8) dispatch retention job - 9) audit logs: - - log run start + completion (status, counts, error_code; no secrets) +## Technical Context -## Retry / Backoff -- Configure job retry behavior based on error classification: - - Throttling/transient (e.g. 429/503): backoff + retry - - Auth/permission (401/403): no retry - - Unknown: limited retries +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3 +**Storage**: PostgreSQL (Sail locally) +**Testing**: Pest v4 +**Target Platform**: Containerized (Sail local), Dokploy deploy (staging/prod) +**Project Type**: Web application (Laravel monolith + Filament admin) +**Performance Goals**: Scheduler runs every minute; per-run work is queued; avoid long locks +**Constraints**: Idempotent dispatch (unique slot), per-schedule concurrency lock, no secrets/tokens in logs, “no catch-up” policy +**Scale/Scope**: Multi-tenant MSP use; schedules per tenant; runs stored for audit/history -## Retention -- `ApplyBackupScheduleRetentionJob(schedule_id)`: - - identify runs ordered newest→oldest - - keep last N runs that created a backup_set_id - - for older ones: soft-delete referenced BackupSets (and cascade soft-delete items) - - audit log: number of deleted BackupSets +## Constitution Check -## Filament UX -- Tenant-scoped resources: - - `BackupScheduleResource` - - Runs UI via RelationManager under schedule (or a dedicated resource if needed) -- Actions: enable/disable, run now, retry -- Notifications: persist via `->sendToDatabase($user)` for the DB info panel. - - MVP notification scope: only interactive actions notify the acting user; scheduled runs rely on Run history. +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -## Ops / Deployment Notes -- Requires queue worker. -- Requires scheduler running. -- Missed runs policy (MVP): no catch-up. +- Safety-First Restore: PASS (feature is backup-only; no restore scheduling) +- Auditability & Tenant Isolation: PASS (tenant_id everywhere; audit log entries for dispatch/run/retention) +- Graph Abstraction & Contracts: PASS (sync uses `GraphClientInterface` via `PolicySyncService`; unknown policy types fail-safe; no hardcoded endpoints) +- Least Privilege: PASS (authorization via TenantRole matrix; no new scopes required beyond existing backup/sync) +- Spec-First Workflow: PASS (spec/plan/tasks/checklist in `specs/032-backup-scheduling-mvp/`) +- Quality Gates: PASS (tasks include Pest coverage per constitution and Pint) + +## Project Structure + +### Documentation (this feature) + +```text +specs/032-backup-scheduling-mvp/ +├── plan.md # This file (/speckit.plan output) +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +└── tasks.md # Phase 2 output (already present) +``` + +### Source Code (repository root) + +```text +app/ +├── Console/Commands/ +├── Filament/Resources/ +├── Jobs/ +├── Models/ +└── Services/ + +config/ +database/migrations/ +routes/console.php +tests/ +``` + +Expected additions for this feature (at implementation time): + +```text +app/Console/Commands/TenantpilotDispatchBackupSchedules.php +app/Jobs/RunBackupScheduleJob.php +app/Jobs/ApplyBackupScheduleRetentionJob.php +app/Models/BackupSchedule.php +app/Models/BackupScheduleRun.php +app/Filament/Resources/BackupScheduleResource.php +database/migrations/*_create_backup_schedules_table.php +database/migrations/*_create_backup_schedule_runs_table.php +tests/Feature/BackupScheduling/* +tests/Unit/BackupScheduling/* +``` + +**Structure Decision**: Laravel monolith (Filament admin + queued jobs). No new top-level app folders. + +## Phase Outputs + +- Phase 0 (Outline & Research): `research.md` +- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md` +- Phase 2 (Tasks): `tasks.md` already exists; will be refined later via `/speckit.tasks` if needed +- Phase 1 (Design & Contracts): `data-model.md`, `contracts/*`, `quickstart.md` diff --git a/specs/032-backup-scheduling-mvp/quickstart.md b/specs/032-backup-scheduling-mvp/quickstart.md new file mode 100644 index 0000000..c853444 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/quickstart.md @@ -0,0 +1,71 @@ +# Quickstart: Backup Scheduling MVP (032) + +This is a developer/operator quickstart for running the scheduling MVP locally with Sail. + +## Prerequisites + +- Laravel Sail running +- Database migrated +- Queue worker running +- Scheduler running (or run the dispatch command manually) + +## Local setup (Sail) + +1) Start Sail + +- `./vendor/bin/sail up -d` + +2) Run migrations + +- `./vendor/bin/sail php artisan migrate` + +3) Start a queue worker + +- `./vendor/bin/sail php artisan queue:work` + +## Run the dispatcher manually (MVP) + +Once a schedule exists, you can dispatch due runs: + +- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch` + +Optional: limit dispatching to specific tenants: + +- `./vendor/bin/sail php artisan tenantpilot:schedules:dispatch --tenant=` + +## Run the Laravel scheduler + +Recommended operations model: + +- Dev/local: run `schedule:work` in a separate terminal + - `./vendor/bin/sail php artisan schedule:work` + +- Production/staging (Dokploy): cron every minute + - `* * * * * php artisan schedule:run` + +## Create a schedule (Filament) + +- Log into Filament admin +- Switch into a tenant context +- Create a Backup Schedule: + - frequency: daily/weekly + - time + timezone + - policy_types: pick from supported types + - retention_keep_last + - include_foundations + +## Verify outcomes + +- In the schedule list: check `Last Run` and `Next Run` +- In run history: verify status, duration, error_code/message +- For successful/partial runs: verify a linked `BackupSet` exists + +Retention + +- After a successful/partial run creates a `BackupSet`, the retention job runs asynchronously and soft-deletes older `BackupSet`s so only the last N (per schedule) remain. + +## Notes + +- Unknown `policy_types` cannot be saved; legacy DB values are handled fail-safe at runtime. +- Scheduled runs do not notify a user; interactive actions (Run now / Retry) should persist a DB notification for the acting user. +- Run now / Retry actions are available for `operator`+ roles. diff --git a/specs/032-backup-scheduling-mvp/research.md b/specs/032-backup-scheduling-mvp/research.md new file mode 100644 index 0000000..5e958b0 --- /dev/null +++ b/specs/032-backup-scheduling-mvp/research.md @@ -0,0 +1,77 @@ +# Research: Backup Scheduling MVP (032) + +**Date**: 2026-01-05 + +This document resolves technical decisions and clarifies implementation approach for Feature 032. + +## Decisions + +### 1) Reuse existing sync + backup services +- **Decision**: Use `App\Services\Intune\PolicySyncService::syncPoliciesWithReport(Tenant $tenant, ?array $supportedTypes = null): array` and `App\Services\Intune\BackupService::createBackupSet(...)`. +- **Rationale**: These are already tenant-aware, use `GraphClientInterface` behind the scenes (via `PolicySyncService`), and `BackupService` already writes a `backup.created` audit log entry. +- **Alternatives considered**: + - Implement new Graph calls directly in the scheduler job → rejected (violates Graph abstraction gate; duplicates logic). + +### 2) Policy type source of truth + validation +- **Decision**: + - Persist `backup_schedules.policy_types` as `array` of **type keys** present in `config('tenantpilot.supported_policy_types')`. + - **Hard validation at save-time**: unknown keys are rejected. + - **Runtime defensive check** (legacy/DB): unknown keys are skipped. + - If ≥1 valid type remains → run becomes `partial` and `error_code=UNKNOWN_POLICY_TYPE`. + - If 0 valid types remain → run becomes `skipped` and `error_code=UNKNOWN_POLICY_TYPE` (no `BackupSet` created). +- **Rationale**: Prevent silent misconfiguration and enforce fail-safe behavior at entry points, while still handling legacy data safely. +- **Alternatives considered**: + - Save unknown keys and ignore silently → rejected (silent misconfiguration). + - Fail the run for any unknown type → rejected (too brittle for legacy). + +### 3) Graph calls and contracts +- **Decision**: Do not hardcode Graph endpoints. All Graph access happens via `GraphClientInterface` (through `PolicySyncService` and `BackupService`). +- **Rationale**: Matches constitution requirements and existing code paths. +- **Alternatives considered**: + - Calling `deviceManagement/{type}` directly → rejected (explicitly forbidden by constitution; also unsafe for unknown types). + +### 4) Scheduling mechanism +- **Decision**: Add an Artisan command `tenantpilot:schedules:dispatch` and register it with Laravel scheduler to run every minute. +- **Rationale**: Fits Laravel 12 structure (no Kernel), supports Dokploy operation models (`schedule:run` cron or `schedule:work`). +- **Alternatives considered**: + - Long-running daemon polling DB directly → rejected (less idiomatic; harder ops). + +### 5) Due calculation + time semantics +- **Decision**: + - `scheduled_for` is minute-slot based and stored in UTC. + - Due calculation uses the schedule timezone. + - DST (MVP): invalid local time → skip; ambiguous local time → first occurrence. +- **Rationale**: Predictable and testable; avoids “surprise catch-up”. +- **Alternatives considered**: + - Catch-up missed slots → rejected by spec (MVP explicitly “no catch-up”). + +### 6) Idempotency + concurrency +- **Decision**: + - DB unique constraint: `(backup_schedule_id, scheduled_for)`. + - Cache lock per schedule (`lock:backup_schedule:{id}`) to prevent parallel execution. + - If lock held, do not run in parallel: mark run `skipped` with a clear error_code. +- **Rationale**: Prevents double runs and provides deterministic behavior. +- **Alternatives considered**: + - Only cache lock (no DB constraint) → rejected (less robust under crashes/restarts). + +### 7) Retry/backoff policy +- **Decision**: + - Transient/throttling failures (e.g. 429/503) → retries with backoff. + - Auth/permission failures (401/403) → no retry. + - Unknown failures → limited retries, then fail. +- **Rationale**: Avoid noisy retry loops for non-recoverable errors. + +### 8) Audit logging +- **Decision**: Use `App\Services\Intune\AuditLogger` for: + - dispatch cycle (optional aggregated) + - run start + completion + - retention applied (count deletions) +- **Rationale**: Constitution requires audit log for every operation; existing `BackupService` already writes `backup.created`. + +### 9) Notifications +- **Decision**: Only interactive actions (Run now / Retry) notify the acting user (database notifications). Scheduled runs rely on Run history. +- **Rationale**: Avoid undefined “who gets notified” without adding new ownership fields. + +## Open Items + +None blocking Phase 1 design. diff --git a/specs/032-backup-scheduling-mvp/spec.md b/specs/032-backup-scheduling-mvp/spec.md index 37f8e8c..45bfd5e 100644 --- a/specs/032-backup-scheduling-mvp/spec.md +++ b/specs/032-backup-scheduling-mvp/spec.md @@ -16,6 +16,14 @@ ## Goals - Retention löscht alte Backups nach Policy. - Filament UI: Schedules verwalten, Run-History ansehen, “Run now”, “Retry”. +## Clarifications + +### Session 2026-01-05 +- Q: Wie sollen wir mit `policy_types` umgehen, die nicht in `config('tenantpilot.supported_policy_types')` enthalten sind? + → A: Beim Speichern hart validieren und ablehnen; zur Laufzeit defensiv re-checken (Legacy/DB), unknown types skippen und Run als `partial` markieren mit `error_code=UNKNOWN_POLICY_TYPE` und Liste betroffener Types. +- Q: Wenn zur Laufzeit alle `policy_types` unbekannt sind (0 valid types nach Skip) – welcher Status? + → A: `skipped` (fail-safe). + ## Non-Goals (MVP) - Kein Kalender-UI als Pflicht (kann später ergänzt werden). - Kein Cross-Tenant Bulk Scheduling (MSP-Templates später). @@ -37,10 +45,14 @@ ### Functional Requirements - **FR-003**: Run nutzt bestehende Services: - Sync Policies (nur selektierte policy types) - Create BackupSet aus lokalen Policy-IDs (inkl. Foundations optional) +- **FR-003a**: `policy_types` sind ausschließlich Keys aus `config('tenantpilot.supported_policy_types')`. +- **FR-003b**: UI/Server-side Validation verhindert das Speichern unbekannter `policy_types`. +- **FR-003c**: Laufzeit-Validierung (defensiv): Unbekannte `policy_types` werden geskippt; wenn mindestens ein gültiger Type verarbeitet wurde, wird der Run als `partial` markiert und `error_code=UNKNOWN_POLICY_TYPE` gesetzt (inkl. Liste der betroffenen Types in `summary`). +- **FR-003d**: Wenn zur Laufzeit nach dem Skip **0 gültige Types** verbleiben, wird **kein BackupSet** erzeugt und der Run als `skipped` markiert (mit `error_code=UNKNOWN_POLICY_TYPE` und Liste der betroffenen Types in `summary`). - **FR-004**: Run schreibt `backup_schedule_runs` mit Status + Summary + Error-Codes. - **FR-005**: “Run now” erzeugt sofort einen Run (scheduled_for=now) und dispatcht Job. - **FR-006**: “Retry” erzeugt einen neuen Run für denselben Schedule. -- **FR-007**: Retention hält nur die letzten N Runs/BackupSets pro Schedule (soft delete BackupSets). +- **FR-007**: Retention hält nur die letzten N BackupSets pro Schedule (soft delete BackupSets). - **FR-008**: Concurrency: Pro Schedule darf nur ein Run gleichzeitig laufen. Wenn bereits ein Run läuft, wird ein neuer Run nicht parallel gestartet und stattdessen als `skipped` markiert (mit Fehlercode). ### UX Requirements (Filament) @@ -114,4 +126,5 @@ ## Acceptance Criteria - UI zeigt Last Run + Next Run + Run-History. - Run now startet sofort. - Fehlerfälle (Token/Permission/Throttle) werden als failed/partial markiert mit error_code. +- Unbekannte `policy_types` können nicht gespeichert werden; falls Legacy-Daten vorkommen, werden sie zur Laufzeit geskippt: mit valid types → `partial`, ohne valid types → `skipped` (jeweils `error_code=UNKNOWN_POLICY_TYPE`). - Retention hält nur die letzten N BackupSets pro Schedule. diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md index 61cef70..03cca66 100644 --- a/specs/032-backup-scheduling-mvp/tasks.md +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -1,42 +1,113 @@ +--- +description: "Task list for feature implementation" +--- + # Tasks: Backup Scheduling MVP (032) -**Date**: 2026-01-05 -**Input**: spec.md, plan.md +**Input**: Design documents from `specs/032-backup-scheduling-mvp/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md -## Phase 1: Spec & Setup -- [ ] T001 Create specs/032-backup-scheduling-mvp (spec/plan/tasks + checklist). +**Tests**: Required by constitution quality gate (Pest) even for MVP. -## Phase 2: Data Model -- [ ] T002 Add migrations: backup_schedules + backup_schedule_runs (tenant-scoped, indexes, unique slot). -- [ ] T003 Add models + relationships (Tenant->schedules, Schedule->runs, Run->backupSet). +## Format: `[ID] [P?] [Story] Description` -## Phase 3: Scheduling + Dispatch -- [ ] T004 Add command `tenantpilot:schedules:dispatch`. -- [ ] T005 Register scheduler to run every minute. -- [ ] T006 Implement due-calculation (timezone, daily/weekly) + next_run_at computation. -- [ ] T007 Implement idempotent run creation (unique slot) + cache lock. +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1/US2/US3) +- Every task description includes at least one explicit file path -## Phase 4: Jobs -- [ ] T008 Implement `RunBackupScheduleJob` (sync -> select policy IDs -> create backup set -> update run + schedule). -- [ ] T009 Implement `ApplyBackupScheduleRetentionJob` (keep last N, soft-delete backup sets). -- [ ] T010 Add error mapping to `error_code` (TOKEN_EXPIRED, PERMISSION_MISSING, GRAPH_THROTTLE, UNKNOWN). - - [ ] T021 Add audit logging for dispatcher/run/retention (tenant-scoped; no secrets). - - [ ] T022 Implement retry/backoff strategy for `RunBackupScheduleJob` (no retry on 401/403). +## User Stories -## Phase 5: Filament UI -- [ ] T011 Add `BackupScheduleResource` (tenant-scoped): CRUD + enable/disable. -- [ ] T012 Add Runs UI (relation manager or resource) with details + link to BackupSet. -- [ ] T013 Add actions: Run now + Retry (permission-gated); notifications persisted to DB. - - [ ] T023 Wire authorization to TenantRole (readonly/operator/manager/owner) for schedule CRUD and run actions. +- **US1 (P1)**: Manage tenant-scoped backup schedules (CRUD, validation). +- **US2 (P1)**: Dispatch + execute runs idempotently (queue/scheduler), write runs + audit logs. +- **US3 (P2)**: View run history, run-now/retry actions, retention keep-last-N. -## Phase 6: Tests -- [ ] T014 Unit: due-calculation + next_run_at. -- [ ] T015 Feature: dispatcher idempotency (unique slot); lock behavior. -- [ ] T016 Job-level: successful run creates backup set, updates run/schedule (Graph mocked). -- [ ] T017 Job-level: token/permission/throttle errors map to error_code and status. -- [ ] T018 Retention: keeps last N and deletes older backup sets. - - [ ] T024 Tests: audit logs written (run success + retention delete) and retry policy behavior. +--- -## Phase 7: Verification -- [ ] T019 Run targeted tests (Pest). -- [ ] T020 Run Pint (`./vendor/bin/pint --dirty`). +## Phase 1: Setup (Shared Infrastructure) + +- [X] T001 [P] [US2] Review existing sync + backup APIs in app/Services/Intune/PolicySyncService.php and app/Services/Intune/BackupService.php +- [X] T002 [P] [US1] Confirm supported policy types config key/shape in config/tenantpilot.php (source of truth for `policy_types`) +- [X] T003 [P] [US2] Confirm audit logging primitives in app/Services/Intune/AuditLogger.php and app/Models/AuditLog.php + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +- [X] T004 [US1] Add migrations for schedules/runs in database/migrations/*_create_backup_schedules_table.php and database/migrations/*_create_backup_schedule_runs_table.php (tenant FKs, jsonb, indexes, unique (backup_schedule_id, scheduled_for)) +- [X] T005 [P] [US1] Add model app/Models/BackupSchedule.php (casts, relationships) +- [X] T006 [P] [US2] Add model app/Models/BackupScheduleRun.php (casts, relationships, status + error fields) +- [X] T007 [P] [US1] Add tenant relationships in app/Models/Tenant.php (backupSchedules(), backupScheduleRuns()) +- [X] T008 [P] [US1] Add TenantRole helpers in app/Support/TenantRole.php for SEC-002 (canManageBackupSchedules(), canRunBackupSchedules()) +- [X] T009 [US2] Implement next-run + slot calculation in app/Services/BackupScheduling/ScheduleTimeService.php (UTC minute-slot, DST rules, no catch-up) +- [X] T010 [US2] Implement policy type validation/filtering in app/Services/BackupScheduling/PolicyTypeResolver.php (validate vs config/tenantpilot.php; runtime filtering for legacy DB) + +--- + +## Phase 3: User Story 1 - Manage Schedules (Priority: P1) + +### Tests (Pest) + +- [X] T011 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleCrudTest.php (tenant scoping + manager/owner CRUD) +- [X] T012 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleValidationTest.php (weekly days_of_week rules; `policy_types` hard validation) +- [X] T013 [P] [US1] Add unit test tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php (next_run_at + DST invalid/ambiguous behavior) + +### Implementation + +- [X] T014 [US1] Implement resource app/Filament/Resources/BackupScheduleResource.php (list columns per UX-001; create/edit form fields) +- [X] T015 [US1] Enforce authorization in app/Filament/Resources/BackupScheduleResource.php using app/Support/TenantRole.php (SEC-002) +- [X] T016 [US1] Persist next_run_at updates via app/Services/BackupScheduling/ScheduleTimeService.php (on create/update) +- [X] T017 [US1] Validate `policy_types` via app/Services/BackupScheduling/PolicyTypeResolver.php (reject unknown keys at save-time) + +--- + +## Phase 4: User Story 2 - Dispatch & Execute Runs (Priority: P1) + +### Tests (Pest) + +- [X] T018 [P] [US2] Add feature test tests/Feature/BackupScheduling/DispatchIdempotencyTest.php (same slot dispatch twice → one run) +- [X] T019 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php (success/partial/skipped outcomes + backup_set linkage) +- [X] T020 [P] [US2] Add feature test tests/Feature/BackupScheduling/RunErrorMappingTest.php (retry/backoff vs no-retry mapping) + +### Implementation + +- [X] T021 [P] [US2] Add command app/Console/Commands/TenantpilotDispatchBackupSchedules.php (tenantpilot:schedules:dispatch) +- [X] T022 [US2] Register scheduler entry in routes/console.php (every minute) +- [X] T023 [US2] Implement dispatcher service app/Services/BackupScheduling/BackupScheduleDispatcher.php (find due schedules, create run, dispatch job) +- [X] T024 [US2] Implement job app/Jobs/RunBackupScheduleJob.php (lock per schedule, sync via app/Services/Intune/PolicySyncService.php, create backup set via app/Services/Intune/BackupService.php, update run + schedule fields) +- [X] T025 [US2] Implement retry/backoff in app/Jobs/RunBackupScheduleJob.php (429/503 backoff; 401/403 no retry; unknown limited retries) +- [X] T026 [US2] Write audit logs (dispatch/run start/run end) using app/Services/Intune/AuditLogger.php from app/Services/BackupScheduling/BackupScheduleDispatcher.php and app/Jobs/RunBackupScheduleJob.php +- [X] T027 [US2] Implement unknown policy types runtime behavior in app/Jobs/RunBackupScheduleJob.php using app/Services/BackupScheduling/PolicyTypeResolver.php (≥1 valid → partial; 0 valid → skipped; error_code UNKNOWN_POLICY_TYPE + list in run summary) + +--- + +## Phase 5: User Story 3 - Run History, Actions, Retention (Priority: P2) + +### Tests (Pest) + +- [X] T028 [P] [US3] Add feature test tests/Feature/BackupScheduling/RunNowRetryActionsTest.php (operator+ allowed; DB notification persisted) +- [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older) +- [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression) +- [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete) + +### Implementation + +- [X] T030 [US3] Add runs RelationManager app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (UX-002 fields + tenant scoping) +- [X] T031 [US3] Add Run now / Retry actions in app/Filament/Resources/BackupScheduleResource.php (SEC-002 gating via app/Support/TenantRole.php) +- [X] T032 [US3] Persist database notifications for interactive actions in app/Filament/Resources/BackupScheduleResource.php +- [X] T033 [US3] Implement retention job app/Jobs/ApplyBackupScheduleRetentionJob.php (soft-delete old backup sets; write audit log) +- [X] T034 [US3] Dispatch retention job from app/Jobs/RunBackupScheduleJob.php after completion + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T035 [P] [US2] Validate operational steps in specs/032-backup-scheduling-mvp/quickstart.md (queue + scheduler + manual dispatch) +- [X] T036 [P] [US2] Run formatter on touched files using vendor/bin/pint --dirty +- [X] T037 [P] [US2] Run targeted tests using vendor/bin/sail php artisan test tests/Feature/BackupScheduling tests/Unit/BackupScheduling + +--- + +## Dependencies & Execution Order + +Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish +Setup (Phase 1) → Foundational (Phase 2) → US1 (P1) → US2 (P1) → US3 (P2) → Polish diff --git a/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php new file mode 100644 index 0000000..a5b1720 --- /dev/null +++ b/tests/Feature/BackupScheduling/ApplyRetentionJobTest.php @@ -0,0 +1,67 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 2, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $sets = collect(range(1, 5))->map(function (int $i) use ($tenant): BackupSet { + return BackupSet::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set '.$i, + 'status' => 'completed', + 'item_count' => 0, + 'completed_at' => now()->subMinutes(10 - $i), + ]); + }); + + // Oldest → newest + $scheduledFor = now('UTC')->startOfMinute()->subMinutes(10); + foreach ($sets as $set) { + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => $scheduledFor, + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0], + 'backup_set_id' => $set->id, + ]); + $scheduledFor = $scheduledFor->addMinute(); + } + + ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id); + + $kept = $sets->take(-2); + $deleted = $sets->take(3); + + foreach ($kept as $set) { + $this->assertDatabaseHas('backup_sets', [ + 'id' => $set->id, + 'deleted_at' => null, + ]); + } + + foreach ($deleted as $set) { + $this->assertSoftDeleted('backup_sets', ['id' => $set->id]); + } +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php b/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php new file mode 100644 index 0000000..f3817d8 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php @@ -0,0 +1,89 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Delete A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Delete B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB])) + ->assertHasNoTableBulkActionErrors(); + + expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count()) + ->toBe(0); +}); + +test('operator cannot bulk delete backup schedules', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Keep A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Keep B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_delete', collect([$scheduleA, $scheduleB])); + } catch (\Throwable) { + // Action should be hidden/blocked for operator users. + } + + expect(BackupSchedule::query()->where('tenant_id', $tenant->id)->count()) + ->toBe(2); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php new file mode 100644 index 0000000..90c9f73 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php @@ -0,0 +1,93 @@ +create(); + + createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager'); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenantA->id, + 'name' => 'Tenant A schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenantB->id, + 'name' => 'Tenant B schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceCompliancePolicy'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + + $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenantA))) + ->assertOk() + ->assertSee('Tenant A schedule') + ->assertSee('Device Configuration') + ->assertDontSee('Tenant B schedule'); +}); + +test('backup schedules pages return 404 for unauthorized tenant', function () { + [$user] = createUserWithTenant(role: 'manager'); + $unauthorizedTenant = Tenant::factory()->create(); + + $this->actingAs($user) + ->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($unauthorizedTenant))) + ->assertNotFound(); +}); + +test('manager can create and edit backup schedules via filament', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Daily at 10', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00', + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $schedule = BackupSchedule::query()->where('tenant_id', $tenant->id)->first(); + expect($schedule)->not->toBeNull(); + expect($schedule->next_run_at)->not->toBeNull(); + + Livewire::test(EditBackupSchedule::class, ['record' => $schedule->getRouteKey()]) + ->fillForm([ + 'name' => 'Daily at 11', + ]) + ->call('save') + ->assertHasNoFormErrors(); + + $schedule->refresh(); + expect($schedule->name)->toBe('Daily at 11'); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php new file mode 100644 index 0000000..ff8d995 --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php @@ -0,0 +1,53 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $backupSet = BackupSet::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Set 174', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => [ + 'policies_total' => 7, + 'policies_backed_up' => 7, + 'errors_count' => 0, + ], + 'error_code' => null, + 'error_message' => null, + 'backup_set_id' => $backupSet->id, + ]); + + $this->actingAs($user); + + $html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render(); + + expect($html)->toContain('Scheduled for'); + expect($html)->toContain('Status'); + expect($html)->toContain('Summary'); + expect($html)->toContain((string) $backupSet->id); +}); diff --git a/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php b/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php new file mode 100644 index 0000000..623effa --- /dev/null +++ b/tests/Feature/BackupScheduling/BackupScheduleValidationTest.php @@ -0,0 +1,48 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Weekly schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'weekly', + 'time_of_day' => '10:00', + 'days_of_week' => [], + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasFormErrors(['days_of_week']); +}); + +test('unknown policy types are rejected at save time', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(CreateBackupSchedule::class) + ->fillForm([ + 'name' => 'Invalid policy type schedule', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00', + 'policy_types' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]) + ->call('create') + ->assertHasFormErrors(['policy_types']); +}); diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php new file mode 100644 index 0000000..8ab996b --- /dev/null +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -0,0 +1,40 @@ +actingAs($user); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + Bus::fake(); + + $dispatcher = app(BackupScheduleDispatcher::class); + + $dispatcher->dispatchDue([$tenant->external_id]); + $dispatcher->dispatchDue([$tenant->external_id]); + + expect(BackupScheduleRun::query()->count())->toBe(1); + + Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); +}); diff --git a/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php new file mode 100644 index 0000000..ffed21c --- /dev/null +++ b/tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php @@ -0,0 +1,123 @@ +actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + app()->bind(PolicySyncService::class, fn () => new class extends PolicySyncService + { + public function __construct() {} + + public function syncPoliciesWithReport($tenant, ?array $supportedTypes = null): array + { + return ['synced' => [], 'failures' => []]; + } + }); + + $backupSet = BackupSet::factory()->create([ + 'tenant_id' => $tenant->id, + 'status' => 'completed', + 'item_count' => 0, + ]); + + app()->bind(BackupService::class, fn () => new class($backupSet) extends BackupService + { + public function __construct(private readonly BackupSet $backupSet) {} + + public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, bool $includeAssignments = false, bool $includeScopeTags = false, bool $includeFoundations = false): BackupSet + { + return $this->backupSet; + } + }); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS); + expect($run->backup_set_id)->toBe($backupSet->id); +}); + +it('skips runs when all policy types are unknown', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['definitelyNotARealPolicyType'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + $run = BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + ]); + + Cache::flush(); + + (new RunBackupScheduleJob($run->id))->handle( + app(PolicySyncService::class), + app(BackupService::class), + app(\App\Services\BackupScheduling\PolicyTypeResolver::class), + app(\App\Services\BackupScheduling\ScheduleTimeService::class), + app(\App\Services\Intune\AuditLogger::class), + app(\App\Services\BackupScheduling\RunErrorMapper::class), + ); + + $run->refresh(); + expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED); + expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE'); + expect($run->backup_set_id)->toBeNull(); +}); diff --git a/tests/Feature/BackupScheduling/RunErrorMappingTest.php b/tests/Feature/BackupScheduling/RunErrorMappingTest.php new file mode 100644 index 0000000..097f092 --- /dev/null +++ b/tests/Feature/BackupScheduling/RunErrorMappingTest.php @@ -0,0 +1,42 @@ +map(new GraphException('auth failed', 401), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeFalse(); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_TOKEN_EXPIRED); +}); + +it('marks 403 as permission missing without retry', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('forbidden', 403), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeFalse(); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_PERMISSION_MISSING); +}); + +it('retries throttling with backoff', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('throttled', 429), attempt: 1, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeTrue(); + expect($mapped['delay'])->toBe(60); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_THROTTLE); +}); + +it('retries service unavailable with backoff', function () { + $mapper = app(RunErrorMapper::class); + + $mapped = $mapper->map(new GraphException('unavailable', 503), attempt: 2, maxAttempts: 3); + + expect($mapped['shouldRetry'])->toBeTrue(); + expect($mapped['delay'])->toBe(300); + expect($mapped['error_code'])->toBe(RunErrorMapper::ERROR_GRAPH_UNAVAILABLE); +}); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php new file mode 100644 index 0000000..276fcf4 --- /dev/null +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -0,0 +1,205 @@ +create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(1); + + $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); + expect($run)->not->toBeNull(); + + Queue::assertPushed(RunBackupScheduleJob::class); + + $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'notifiable_type' => User::class, + ]); +}); + +test('operator can retry and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class); + $this->assertDatabaseCount('notifications', 1); +}); + +test('readonly cannot dispatch run now or retry', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableAction('runNow', $schedule); + } catch (\Throwable) { + // Action should be hidden/blocked for readonly users. + } + + try { + Livewire::test(ListBackupSchedules::class) + ->callTableAction('retry', $schedule); + } catch (\Throwable) { + // Action should be hidden/blocked for readonly users. + } + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) + ->toBe(0); +}); + +test('operator can bulk run now and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(2); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 1); +}); + +test('operator can bulk retry and it persists a database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) + ->toBe(2); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); + $this->assertDatabaseCount('notifications', 1); +}); diff --git a/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php b/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php new file mode 100644 index 0000000..e73b14c --- /dev/null +++ b/tests/Unit/BackupScheduling/ScheduleTimeServiceTest.php @@ -0,0 +1,45 @@ +forceFill([ + 'frequency' => 'daily', + 'timezone' => 'Europe/Berlin', + 'time_of_day' => '02:30:00', + 'days_of_week' => [], + ]); + + $service = app(ScheduleTimeService::class); + + // On 2026-03-29 in Europe/Berlin, the clock jumps from 02:00 to 03:00 (02:30 is nonexistent). + // Using an "after" cursor later than 02:30 on the previous day forces the candidate day to be 2026-03-29. + $after = CarbonImmutable::create(2026, 3, 28, 3, 0, 0, 'Europe/Berlin'); + + $next = $service->nextRunFor($schedule, $after); + + expect($next)->not->toBeNull(); + expect($next->timezone('UTC')->format('Y-m-d H:i:s'))->toBe('2026-03-30 00:30:00'); +}); + +it('returns null for weekly schedules without allowed days', function () { + $schedule = new BackupSchedule; + $schedule->forceFill([ + 'frequency' => 'weekly', + 'timezone' => 'UTC', + 'time_of_day' => '10:00:00', + 'days_of_week' => [], + ]); + + $service = app(ScheduleTimeService::class); + + $next = $service->nextRunFor($schedule, CarbonImmutable::create(2026, 1, 5, 0, 0, 0, 'UTC')); + + expect($next)->toBeNull(); +}); From a62c8558515ea5344f2f87a1df00ba1c48ae1384 Mon Sep 17 00:00:00 2001 From: ahmido Date: Wed, 7 Jan 2026 01:12:12 +0000 Subject: [PATCH 18/18] feat/032-backup-scheduling-mvp (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Backup Scheduling MVP (CRUD, dispatcher, run job, retention, audit logs) Run now / Retry persist Filament DB notifications Bulk Run/Retry now create BulkOperationRun so bottom-right progress widget shows them Progress widget includes “recent finished” window + reconciles stale backup bulk runs Adds purge command + migration backup_schedule_runs.user_id + tests updates Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/36 --- README.md | 7 + .../TenantpilotPurgeNonPersistentData.php | 164 +++++++ .../Resources/BackupScheduleResource.php | 435 +++++++++++------- app/Jobs/RunBackupScheduleJob.php | 128 +++++- app/Livewire/BulkOperationProgress.php | 97 +++- app/Models/BackupScheduleRun.php | 5 + .../BackupScheduleDispatcher.php | 14 +- app/Services/BulkOperationService.php | 18 +- ..._user_id_to_backup_schedule_runs_table.php | 35 ++ .../bulk-operation-progress.blade.php | 7 +- specs/032-backup-scheduling-mvp/tasks.md | 2 + .../BackupScheduleCrudTest.php | 32 +- .../DispatchIdempotencyTest.php | 41 ++ .../RunNowRetryActionsTest.php | 126 +++++ .../Feature/BulkProgressNotificationTest.php | 56 +++ .../PurgeNonPersistentDataCommandTest.php | 143 ++++++ 16 files changed, 1144 insertions(+), 166 deletions(-) create mode 100644 app/Console/Commands/TenantpilotPurgeNonPersistentData.php create mode 100644 database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php create mode 100644 tests/Feature/Console/PurgeNonPersistentDataCommandTest.php diff --git a/README.md b/README.md index ddc34ef..edb6af5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ ## Bulk operations (Feature 005) - Destructive operations require type-to-confirm at higher thresholds (e.g. `DELETE`). - Long-running bulk ops are queued; the bottom-right progress widget polls for active runs. +### Troubleshooting + +- **Progress stuck on “Queued…”** usually means the queue worker is not running (or not processing the queue you expect). + - Prefer using the Sail/Docker worker (see `docker-compose.yml`) rather than starting an additional local `php artisan queue:work`. + - Check worker status/logs: `./vendor/bin/sail ps` and `./vendor/bin/sail logs -f queue`. +- **Exit code 137** for `queue:work` typically means the process was killed (often OOM). Increase Docker memory/limits or run the worker inside the container. + ### Configuration - `TENANTPILOT_BULK_CHUNK_SIZE` (default `10`): job refresh/progress chunk size. diff --git a/app/Console/Commands/TenantpilotPurgeNonPersistentData.php b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php new file mode 100644 index 0000000..4b35693 --- /dev/null +++ b/app/Console/Commands/TenantpilotPurgeNonPersistentData.php @@ -0,0 +1,164 @@ +resolveTenants(); + + if ($tenants->isEmpty()) { + $this->error('No tenants selected. Provide {tenant} or use --all.'); + + return self::FAILURE; + } + + $isDryRun = ! (bool) $this->option('force'); + + if ($isDryRun) { + $this->warn('Dry run: no rows will be deleted. Re-run with --force to apply.'); + } else { + $this->warn('This will PERMANENTLY delete non-persistent tenant data.'); + + if ($this->input->isInteractive() && ! $this->confirm('Proceed?', false)) { + $this->info('Aborted.'); + + return self::SUCCESS; + } + } + + foreach ($tenants as $tenant) { + $counts = $this->countsForTenant($tenant); + + $this->line(''); + $this->info("Tenant: {$tenant->id} ({$tenant->name})"); + $this->table( + ['Table', 'Rows'], + collect($counts) + ->map(fn (int $count, string $table) => [$table, $count]) + ->values() + ->all(), + ); + + if ($isDryRun) { + continue; + } + + DB::transaction(function () use ($tenant): void { + BackupScheduleRun::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + BackupSchedule::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + AuditLog::query() + ->where('tenant_id', $tenant->id) + ->delete(); + + RestoreRun::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + BackupItem::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + BackupSet::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + PolicyVersion::withTrashed() + ->where('tenant_id', $tenant->id) + ->forceDelete(); + + Policy::query() + ->where('tenant_id', $tenant->id) + ->delete(); + }); + + $this->info('Purged.'); + } + + return self::SUCCESS; + } + + private function resolveTenants() + { + if ((bool) $this->option('all')) { + return Tenant::query()->get(); + } + + $tenantArg = $this->argument('tenant'); + + if ($tenantArg !== null && $tenantArg !== '') { + $tenant = Tenant::query()->forTenant($tenantArg)->first(); + + return $tenant ? collect([$tenant]) : collect(); + } + + try { + return collect([Tenant::current()]); + } catch (RuntimeException) { + return collect(); + } + } + + /** + * @return array + */ + private function countsForTenant(Tenant $tenant): array + { + return [ + 'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(), + 'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(), + 'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(), + 'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(), + 'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'backup_sets' => BackupSet::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'policy_versions' => PolicyVersion::withTrashed()->where('tenant_id', $tenant->id)->count(), + 'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(), + ]; + } +} diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 8ef28ce..a16c996 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -10,10 +10,10 @@ use App\Models\BackupScheduleRun; use App\Models\Tenant; use App\Models\User; -use App\Notifications\BackupScheduleRunDispatchedNotification; use App\Rules\SupportedPolicyTypesRule; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\ScheduleTimeService; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Support\TenantRole; use BackedEnum; @@ -30,6 +30,7 @@ use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Notifications\Events\DatabaseNotificationsSent; use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Utilities\Get; @@ -41,6 +42,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -200,50 +202,8 @@ public static function table(Table $table): Table TextColumn::make('policy_types') ->label('Policy types') - ->wrap() - ->getStateUsing(function (BackupSchedule $record): string { - $state = $record->policy_types; - - if (is_string($state)) { - $decoded = json_decode($state, true); - - if (is_array($decoded)) { - $state = $decoded; - } - } - - if ($state instanceof \Illuminate\Contracts\Support\Arrayable) { - $state = $state->toArray(); - } - - if (! is_array($state)) { - return 'None'; - } - - $types = array_is_list($state) - ? $state - : array_keys(array_filter($state)); - - $types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')); - - if ($types === []) { - return 'None'; - } - - $labelMap = collect(config('tenantpilot.supported_policy_types', [])) - ->mapWithKeys(fn (array $policy): array => [ - (string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))), - ]) - ->filter(fn (string $label, string $type): bool => $type !== '') - ->all(); - - $labels = array_map( - fn (string $type): string => $labelMap[$type] ?? Str::headline($type), - $types, - ); - - return implode(', ', $labels); - }), + ->getStateUsing(fn (BackupSchedule $record): string => static::policyTypesPreviewLabel($record)) + ->tooltip(fn (BackupSchedule $record): string => static::policyTypesFullLabel($record)), TextColumn::make('retention_keep_last') ->label('Retention') @@ -278,7 +238,21 @@ public static function table(Table $table): Table TextColumn::make('next_run_at') ->label('Next run') - ->dateTime() + ->getStateUsing(function (BackupSchedule $record): ?string { + $nextRun = $record->next_run_at; + + if (! $nextRun) { + return null; + } + + $timezone = $record->timezone ?: 'UTC'; + + try { + return $nextRun->setTimezone($timezone)->format('M j, Y H:i:s'); + } catch (\Throwable) { + return $nextRun->format('M j, Y H:i:s'); + } + }) ->sortable(), ]) ->filters([ @@ -318,28 +292,37 @@ public static function table(Table $table): Table $tenant = Tenant::current(); $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + for ($i = 0; $i < 5; $i++) { - $exists = BackupScheduleRun::query() - ->where('backup_schedule_id', $record->id) - ->where('scheduled_for', $scheduledFor) - ->exists(); - - if (! $exists) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); } - - $scheduledFor = $scheduledFor->addMinute(); } - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Run already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->send(); + + return; + } app(AuditLogger::class)->log( tenant: $tenant, @@ -357,23 +340,34 @@ public static function table(Table $table): Table ], ); - Bus::dispatch(new RunBackupScheduleJob($run->id)); + $bulkRunId = null; - if ($user instanceof User) { - $user->notify(new BackupScheduleRunDispatchedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'backup_schedule_id' => (int) $record->id, - 'backup_schedule_run_id' => (int) $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'run_now', - ])); + if ($userModel instanceof User) { + $bulkRunId = app(BulkOperationService::class) + ->createRun( + tenant: $tenant, + user: $userModel, + resource: 'backup_schedule', + action: 'run', + itemIds: [(string) $record->id], + totalItems: 1, + ) + ->id; } - Notification::make() + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId)); + + $notification = Notification::make() ->title('Run dispatched') ->body('The backup run has been queued.') - ->success() - ->send(); + ->success(); + + if ($userModel instanceof User) { + $userModel->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($userModel); + } + + $notification->send(); }), Action::make('retry') ->label('Retry') @@ -385,28 +379,37 @@ public static function table(Table $table): Table $tenant = Tenant::current(); $user = auth()->user(); + $userId = auth()->id(); + $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + for ($i = 0; $i < 5; $i++) { - $exists = BackupScheduleRun::query() - ->where('backup_schedule_id', $record->id) - ->where('scheduled_for', $scheduledFor) - ->exists(); - - if (! $exists) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); } - - $scheduledFor = $scheduledFor->addMinute(); } - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + if (! $run instanceof BackupScheduleRun) { + Notification::make() + ->title('Retry already queued') + ->body('Please wait a moment and try again.') + ->warning() + ->send(); + + return; + } app(AuditLogger::class)->log( tenant: $tenant, @@ -424,23 +427,34 @@ public static function table(Table $table): Table ], ); - Bus::dispatch(new RunBackupScheduleJob($run->id)); + $bulkRunId = null; - if ($user instanceof User) { - $user->notify(new BackupScheduleRunDispatchedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'backup_schedule_id' => (int) $record->id, - 'backup_schedule_run_id' => (int) $run->id, - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'trigger' => 'retry', - ])); + if ($userModel instanceof User) { + $bulkRunId = app(BulkOperationService::class) + ->createRun( + tenant: $tenant, + user: $userModel, + resource: 'backup_schedule', + action: 'retry', + itemIds: [(string) $record->id], + totalItems: 1, + ) + ->id; } - Notification::make() + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId)); + + $notification = Notification::make() ->title('Retry dispatched') ->body('A new backup run has been queued.') - ->success() - ->send(); + ->success(); + + if ($userModel instanceof User) { + $userModel->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($userModel); + } + + $notification->send(); }), EditAction::make() ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), @@ -463,33 +477,47 @@ public static function table(Table $table): Table } $tenant = Tenant::current(); - $user = auth()->user(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + + $bulkRun = null; + if ($user) { + $bulkRun = app(\App\Services\BulkOperationService::class)->createRun( + tenant: $tenant, + user: $user, + resource: 'backup_schedule', + action: 'run', + itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(), + totalItems: $records->count(), + ); + } $createdRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + for ($i = 0; $i < 5; $i++) { - $exists = BackupScheduleRun::query() - ->where('backup_schedule_id', $record->id) - ->where('scheduled_for', $scheduledFor) - ->exists(); - - if (! $exists) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); } - - $scheduledFor = $scheduledFor->addMinute(); } - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + if (! $run instanceof BackupScheduleRun) { + continue; + } $createdRunIds[] = (int) $run->id; @@ -505,28 +533,30 @@ public static function table(Table $table): Table 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'bulk_run_now', + 'bulk_run_id' => $bulkRun?->id, ], ], ); - Bus::dispatch(new RunBackupScheduleJob($run->id)); + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id)); + } + + $notification = Notification::make() + ->title('Runs dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); } if ($user instanceof User) { - $user->notify(new BackupScheduleRunDispatchedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), - 'backup_schedule_run_ids' => $createdRunIds, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), - 'trigger' => 'bulk_run_now', - ])); + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); } - Notification::make() - ->title('Runs dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))) - ->success() - ->send(); + $notification->send(); }), BulkAction::make('bulk_retry') ->label('Retry') @@ -541,33 +571,47 @@ public static function table(Table $table): Table } $tenant = Tenant::current(); - $user = auth()->user(); + $userId = auth()->id(); + $user = $userId ? User::query()->find($userId) : null; + + $bulkRun = null; + if ($user) { + $bulkRun = app(\App\Services\BulkOperationService::class)->createRun( + tenant: $tenant, + user: $user, + resource: 'backup_schedule', + action: 'retry', + itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(), + totalItems: $records->count(), + ); + } $createdRunIds = []; /** @var BackupSchedule $record */ foreach ($records as $record) { $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + $run = null; + for ($i = 0; $i < 5; $i++) { - $exists = BackupScheduleRun::query() - ->where('backup_schedule_id', $record->id) - ->where('scheduled_for', $scheduledFor) - ->exists(); - - if (! $exists) { + try { + $run = BackupScheduleRun::create([ + 'backup_schedule_id' => $record->id, + 'tenant_id' => $tenant->getKey(), + 'user_id' => $userId, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); break; + } catch (UniqueConstraintViolationException) { + $scheduledFor = $scheduledFor->addMinute(); } - - $scheduledFor = $scheduledFor->addMinute(); } - $run = BackupScheduleRun::create([ - 'backup_schedule_id' => $record->id, - 'tenant_id' => $tenant->getKey(), - 'scheduled_for' => $scheduledFor->toDateTimeString(), - 'status' => BackupScheduleRun::STATUS_RUNNING, - 'summary' => null, - ]); + if (! $run instanceof BackupScheduleRun) { + continue; + } $createdRunIds[] = (int) $run->id; @@ -583,28 +627,30 @@ public static function table(Table $table): Table 'backup_schedule_run_id' => $run->id, 'scheduled_for' => $scheduledFor->toDateTimeString(), 'trigger' => 'bulk_retry', + 'bulk_run_id' => $bulkRun?->id, ], ], ); - Bus::dispatch(new RunBackupScheduleJob($run->id)); + Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id)); + } + + $notification = Notification::make() + ->title('Retries dispatched') + ->body(sprintf('Queued %d run(s).', count($createdRunIds))); + + if (count($createdRunIds) === 0) { + $notification->warning(); + } else { + $notification->success(); } if ($user instanceof User) { - $user->notify(new BackupScheduleRunDispatchedNotification([ - 'tenant_id' => (int) $tenant->getKey(), - 'schedule_ids' => $records->pluck('id')->map(fn ($id) => (int) $id)->values()->all(), - 'backup_schedule_run_ids' => $createdRunIds, - 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), - 'trigger' => 'bulk_retry', - ])); + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); } - Notification::make() - ->title('Retries dispatched') - ->body(sprintf('Queued %d run(s).', count($createdRunIds))) - ->success() - ->send(); + $notification->send(); }), DeleteBulkAction::make('bulk_delete') ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), @@ -638,6 +684,79 @@ public static function getPages(): array ]; } + public static function policyTypesFullLabel(BackupSchedule $record): string + { + $labels = static::policyTypesLabels($record); + + return $labels === [] ? 'None' : implode(', ', $labels); + } + + public static function policyTypesPreviewLabel(BackupSchedule $record): string + { + $labels = static::policyTypesLabels($record); + + if ($labels === []) { + return 'None'; + } + + $preview = array_slice($labels, 0, 2); + $remaining = count($labels) - count($preview); + + $label = implode(', ', $preview); + + if ($remaining > 0) { + $label .= sprintf(' +%d more', $remaining); + } + + return $label; + } + + /** + * @return array + */ + private static function policyTypesLabels(BackupSchedule $record): array + { + $state = $record->policy_types; + + if (is_string($state)) { + $decoded = json_decode($state, true); + + if (is_array($decoded)) { + $state = $decoded; + } + } + + if ($state instanceof \Illuminate\Contracts\Support\Arrayable) { + $state = $state->toArray(); + } + + if (blank($state) || (! is_array($state))) { + return []; + } + + $types = array_is_list($state) + ? $state + : array_keys(array_filter($state)); + + $types = array_values(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')); + + if ($types === []) { + return []; + } + + $labelMap = collect(config('tenantpilot.supported_policy_types', [])) + ->mapWithKeys(fn (array $policy): array => [ + (string) ($policy['type'] ?? '') => (string) ($policy['label'] ?? Str::headline((string) ($policy['type'] ?? ''))), + ]) + ->filter(fn (string $label, string $type): bool => $type !== '') + ->all(); + + return array_map( + fn (string $type): string => $labelMap[$type] ?? Str::headline($type), + $types, + ); + } + public static function ensurePolicyTypes(array $data): array { $types = array_values((array) ($data['policy_types'] ?? [])); diff --git a/app/Jobs/RunBackupScheduleJob.php b/app/Jobs/RunBackupScheduleJob.php index 59a3524..23c802b 100644 --- a/app/Jobs/RunBackupScheduleJob.php +++ b/app/Jobs/RunBackupScheduleJob.php @@ -4,13 +4,17 @@ use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; +use App\Models\BulkOperationRun; use App\Services\BackupScheduling\PolicyTypeResolver; use App\Services\BackupScheduling\RunErrorMapper; use App\Services\BackupScheduling\ScheduleTimeService; +use App\Services\BulkOperationService; use App\Services\Intune\AuditLogger; use App\Services\Intune\BackupService; use App\Services\Intune\PolicySyncService; use Carbon\CarbonImmutable; +use Filament\Notifications\Events\DatabaseNotificationsSent; +use Filament\Notifications\Notification; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -26,7 +30,10 @@ class RunBackupScheduleJob implements ShouldQueue public int $tries = 3; - public function __construct(public int $backupScheduleRunId) {} + public function __construct( + public int $backupScheduleRunId, + public ?int $bulkRunId = null, + ) {} public function handle( PolicySyncService $policySyncService, @@ -35,15 +42,31 @@ public function handle( ScheduleTimeService $scheduleTimeService, AuditLogger $auditLogger, RunErrorMapper $errorMapper, + BulkOperationService $bulkOperationService, ): void { $run = BackupScheduleRun::query() - ->with(['schedule', 'tenant']) + ->with(['schedule', 'tenant', 'user']) ->find($this->backupScheduleRunId); if (! $run) { return; } + $bulkRun = $this->bulkRunId + ? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId) + : null; + + if ( + $bulkRun + && ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id) + ) { + $bulkRun = null; + } + + if ($bulkRun && $bulkRun->status === 'pending') { + $bulkOperationService->start($bulkRun); + } + $schedule = $run->schedule; if (! $schedule instanceof BackupSchedule) { @@ -81,6 +104,7 @@ public function handle( errorMessage: 'Another run is already in progress for this schedule.', summary: ['reason' => 'concurrent_run'], scheduleTimeService: $scheduleTimeService, + bulkRunId: $this->bulkRunId, ); return; @@ -94,6 +118,8 @@ public function handle( 'status' => BackupScheduleRun::STATUS_RUNNING, ])->save(); + $this->notifyRunStarted($run, $schedule); + $auditLogger->log( tenant: $tenant, action: 'backup_schedule.run_started', @@ -124,6 +150,7 @@ public function handle( 'unknown_policy_types' => $unknownTypes, ], scheduleTimeService: $scheduleTimeService, + bulkRunId: $this->bulkRunId, ); return; @@ -182,6 +209,7 @@ public function handle( summary: $summary, scheduleTimeService: $scheduleTimeService, backupSetId: (string) $backupSet->id, + bulkRunId: $this->bulkRunId, ); $auditLogger->log( @@ -220,6 +248,7 @@ public function handle( 'attempt' => $attempt, ], scheduleTimeService: $scheduleTimeService, + bulkRunId: $this->bulkRunId, ); $auditLogger->log( @@ -241,6 +270,56 @@ public function handle( } } + private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void + { + $user = $run->user; + + if (! $user) { + return; + } + + $notification = Notification::make() + ->title('Backup started') + ->body(sprintf('Schedule "%s" has started.', $schedule->name)) + ->info(); + + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); + } + + private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void + { + $user = $run->user; + + if (! $user) { + return; + } + + $title = match ($run->status) { + BackupScheduleRun::STATUS_SUCCESS => 'Backup completed', + BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)', + BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped', + default => 'Backup failed', + }; + + $notification = Notification::make() + ->title($title) + ->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status)); + + if (filled($run->error_message)) { + $notification->body($notification->getBody()."\n".$run->error_message); + } + + match ($run->status) { + BackupScheduleRun::STATUS_SUCCESS => $notification->success(), + BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(), + default => $notification->danger(), + }; + + $user->notifyNow($notification->toDatabase()); + DatabaseNotificationsSent::dispatch($user); + } + private function finishRun( BackupScheduleRun $run, BackupSchedule $schedule, @@ -250,6 +329,7 @@ private function finishRun( array $summary, ScheduleTimeService $scheduleTimeService, ?string $backupSetId = null, + ?int $bulkRunId = null, ): void { $nowUtc = CarbonImmutable::now('UTC'); @@ -268,6 +348,50 @@ private function finishRun( 'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc), ])->saveQuietly(); + $this->notifyRunFinished($run, $schedule); + + if ($bulkRunId) { + $bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId); + + if ( + $bulkRun + && ($bulkRun->tenant_id === $run->tenant_id) + && ($bulkRun->user_id === $run->user_id) + && in_array($bulkRun->status, ['pending', 'running'], true) + ) { + $service = app(BulkOperationService::class); + + $itemId = (string) $run->backup_schedule_id; + + match ($status) { + BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun), + BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason( + $bulkRun, + $itemId, + $errorMessage ?: 'Skipped', + ), + BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure( + $bulkRun, + $itemId, + $errorMessage ?: 'Completed partially', + ), + default => $service->recordFailure( + $bulkRun, + $itemId, + $errorMessage ?: ($errorCode ?: 'Failed'), + ), + }; + + $bulkRun->refresh(); + if ( + in_array($bulkRun->status, ['pending', 'running'], true) + && $bulkRun->processed_items >= $bulkRun->total_items + ) { + $service->complete($bulkRun); + } + } + } + if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) { Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id)); } diff --git a/app/Livewire/BulkOperationProgress.php b/app/Livewire/BulkOperationProgress.php index 975a619..a7ef31d 100644 --- a/app/Livewire/BulkOperationProgress.php +++ b/app/Livewire/BulkOperationProgress.php @@ -2,8 +2,10 @@ namespace App\Livewire; +use App\Models\BackupScheduleRun; use App\Models\BulkOperationRun; use App\Models\Tenant; +use Illuminate\Support\Arr; use Livewire\Attributes\Computed; use Livewire\Component; @@ -13,9 +15,12 @@ class BulkOperationProgress extends Component public int $pollSeconds = 3; + public int $recentFinishedSeconds = 12; + public function mount() { $this->pollSeconds = max(1, min(10, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); + $this->recentFinishedSeconds = max(3, min(60, (int) config('tenantpilot.bulk_operations.recent_finished_seconds', 12))); $this->loadRuns(); } @@ -35,12 +40,102 @@ public function loadRuns() return; } + $recentThreshold = now()->subSeconds($this->recentFinishedSeconds); + $this->runs = BulkOperationRun::query() ->where('tenant_id', $tenant->id) ->where('user_id', auth()->id()) - ->whereIn('status', ['pending', 'running']) + ->where(function ($query) use ($recentThreshold): void { + $query->whereIn('status', ['pending', 'running']) + ->orWhere(function ($query) use ($recentThreshold): void { + $query->whereIn('status', ['completed', 'completed_with_errors', 'failed', 'aborted']) + ->where('updated_at', '>=', $recentThreshold); + }); + }) ->orderByDesc('created_at') ->get(); + + $this->reconcileBackupScheduleRuns($tenant->id); + } + + private function reconcileBackupScheduleRuns(int $tenantId): void + { + $userId = auth()->id(); + + if (! $userId) { + return; + } + + $staleThreshold = now()->subSeconds(60); + + foreach ($this->runs as $bulkRun) { + if ($bulkRun->resource !== 'backup_schedule') { + continue; + } + + if (! in_array($bulkRun->status, ['pending', 'running'], true)) { + continue; + } + + if (! $bulkRun->created_at || $bulkRun->created_at->gt($staleThreshold)) { + continue; + } + + $scheduleId = (int) Arr::first($bulkRun->item_ids ?? []); + + if ($scheduleId <= 0) { + continue; + } + + $scheduleRun = BackupScheduleRun::query() + ->where('tenant_id', $tenantId) + ->where('user_id', $userId) + ->where('backup_schedule_id', $scheduleId) + ->where('created_at', '>=', $bulkRun->created_at) + ->orderByDesc('id') + ->first(); + + if (! $scheduleRun) { + continue; + } + + if ($scheduleRun->finished_at) { + $processed = 1; + $succeeded = 0; + $failed = 0; + $skipped = 0; + $status = 'completed'; + + switch ($scheduleRun->status) { + case BackupScheduleRun::STATUS_SUCCESS: + $succeeded = 1; + break; + + case BackupScheduleRun::STATUS_SKIPPED: + $skipped = 1; + break; + + default: + $failed = 1; + $status = 'completed_with_errors'; + break; + } + + $bulkRun->forceFill([ + 'status' => $status, + 'processed_items' => $processed, + 'succeeded' => $succeeded, + 'failed' => $failed, + 'skipped' => $skipped, + ])->save(); + + continue; + } + + if ($scheduleRun->started_at && $bulkRun->status === 'pending') { + $bulkRun->forceFill(['status' => 'running'])->save(); + } + } } public function render(): \Illuminate\Contracts\View\View diff --git a/app/Models/BackupScheduleRun.php b/app/Models/BackupScheduleRun.php index 76feb90..4091b20 100644 --- a/app/Models/BackupScheduleRun.php +++ b/app/Models/BackupScheduleRun.php @@ -41,6 +41,11 @@ public function tenant(): BelongsTo return $this->belongsTo(Tenant::class); } + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + public function backupSet(): BelongsTo { return $this->belongsTo(BackupSet::class); diff --git a/app/Services/BackupScheduling/BackupScheduleDispatcher.php b/app/Services/BackupScheduling/BackupScheduleDispatcher.php index f0bd207..be63a73 100644 --- a/app/Services/BackupScheduling/BackupScheduleDispatcher.php +++ b/app/Services/BackupScheduling/BackupScheduleDispatcher.php @@ -8,8 +8,9 @@ use App\Models\Tenant; use App\Services\Intune\AuditLogger; use Carbon\CarbonImmutable; -use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Log; class BackupScheduleDispatcher { @@ -71,10 +72,19 @@ public function dispatchDue(?array $tenantIdentifiers = null): array 'status' => BackupScheduleRun::STATUS_RUNNING, 'summary' => null, ]); - } catch (QueryException $exception) { + } catch (UniqueConstraintViolationException) { // Idempotency: unique (backup_schedule_id, scheduled_for) $skippedRuns++; + Log::debug('Backup schedule run already dispatched for slot.', [ + 'schedule_id' => $schedule->id, + 'slot' => $slot->toDateTimeString(), + ]); + + $schedule->forceFill([ + 'next_run_at' => $this->scheduleTimeService->nextRunFor($schedule, $nowUtc), + ])->saveQuietly(); + continue; } diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index 76b3af7..8f3f158 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -109,8 +109,24 @@ public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, s public function complete(BulkOperationRun $run): void { + $run->refresh(); + + if (! in_array($run->status, ['pending', 'running'], true)) { + return; + } + $status = $run->failed > 0 ? 'completed_with_errors' : 'completed'; - $run->update(['status' => $status]); + + $updated = BulkOperationRun::query() + ->whereKey($run->id) + ->whereIn('status', ['pending', 'running']) + ->update(['status' => $status]); + + if ($updated === 0) { + return; + } + + $run->refresh(); $failureEntries = collect($run->failures ?? []); $failedReasons = $failureEntries diff --git a/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php b/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php new file mode 100644 index 0000000..b01bc37 --- /dev/null +++ b/database/migrations/2026_01_06_211013_add_user_id_to_backup_schedule_runs_table.php @@ -0,0 +1,35 @@ +foreignId('user_id') + ->nullable() + ->after('tenant_id') + ->constrained() + ->nullOnDelete(); + + $table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_schedule_runs', function (Blueprint $table) { + $table->dropIndex('backup_schedule_runs_user_created'); + $table->dropConstrainedForeignId('user_id'); + }); + } +}; diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index c254211..faf75fb 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -13,12 +13,13 @@

@if($run->status === 'pending') + @php($isStalePending = $run->created_at->lt(now()->subSeconds(30))) - Starting... + {{ $isStalePending ? 'Queued…' : 'Starting...' }} @elseif($run->status === 'running') @@ -28,6 +29,10 @@ Processing... + @elseif(in_array($run->status, ['completed', 'completed_with_errors'], true)) + Done + @elseif(in_array($run->status, ['failed', 'aborted'], true)) + Failed @endif

diff --git a/specs/032-backup-scheduling-mvp/tasks.md b/specs/032-backup-scheduling-mvp/tasks.md index 03cca66..ee1571c 100644 --- a/specs/032-backup-scheduling-mvp/tasks.md +++ b/specs/032-backup-scheduling-mvp/tasks.md @@ -88,6 +88,8 @@ ### Tests (Pest) - [X] T029 [P] [US3] Add feature test tests/Feature/BackupScheduling/ApplyRetentionJobTest.php (keeps last N backup_sets; soft-deletes older) - [X] T038 [P] [US1] Add feature test tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (bulk delete action regression) - [X] T039 [P] [US1] Extend tests/Feature/BackupScheduling/BackupScheduleBulkDeleteTest.php (operator cannot bulk delete) +- [X] T041 [P] [US3] Make manual dispatch actions idempotent under concurrency in app/Filament/Resources/BackupScheduleResource.php (avoid unique constraint 500); add regression in tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +- [X] T042 [P] [US2] Harden dispatcher idempotency in app/Services/BackupScheduling/BackupScheduleDispatcher.php (catch unique constraint only; treat as already dispatched, no side effects) and extend tests/Feature/BackupScheduling/DispatchIdempotencyTest.php ### Implementation diff --git a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php index 90c9f73..f7c6726 100644 --- a/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php +++ b/tests/Feature/BackupScheduling/BackupScheduleCrudTest.php @@ -4,6 +4,7 @@ use App\Filament\Resources\BackupScheduleResource\Pages\EditBackupSchedule; use App\Models\BackupSchedule; use App\Models\Tenant; +use Carbon\CarbonImmutable; use Filament\Facades\Filament; use Livewire\Livewire; @@ -21,7 +22,11 @@ 'frequency' => 'daily', 'time_of_day' => '01:00:00', 'days_of_week' => null, - 'policy_types' => ['deviceConfiguration'], + 'policy_types' => [ + 'deviceConfiguration', + 'groupPolicyConfiguration', + 'settingsCatalogPolicy', + ], 'include_foundations' => true, 'retention_keep_last' => 30, ]); @@ -45,9 +50,34 @@ ->assertOk() ->assertSee('Tenant A schedule') ->assertSee('Device Configuration') + ->assertSee('more') ->assertDontSee('Tenant B schedule'); }); +test('backup schedules listing shows next run in schedule timezone', function () { + [$user, $tenant] = createUserWithTenant(role: 'manager'); + + BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Berlin schedule', + 'is_enabled' => true, + 'timezone' => 'Europe/Berlin', + 'frequency' => 'daily', + 'time_of_day' => '10:17:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => CarbonImmutable::create(2026, 1, 5, 9, 17, 0, 'UTC'), + ]); + + $this->actingAs($user); + + $this->get(route('filament.admin.resources.backup-schedules.index', filamentTenantRouteParams($tenant))) + ->assertOk() + ->assertSee('Jan 5, 2026 10:17:00'); +}); + test('backup schedules pages return 404 for unauthorized tenant', function () { [$user] = createUserWithTenant(role: 'manager'); $unauthorizedTenant = Tenant::factory()->create(); diff --git a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php index 8ab996b..8df6cd6 100644 --- a/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php +++ b/tests/Feature/BackupScheduling/DispatchIdempotencyTest.php @@ -38,3 +38,44 @@ Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1); }); + +it('treats a unique constraint collision as already-dispatched and advances next_run_at', function () { + CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC')); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Daily 10:00', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => null, + ]); + + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + Bus::fake(); + + $dispatcher = app(BackupScheduleDispatcher::class); + $dispatcher->dispatchDue([$tenant->external_id]); + + expect(BackupScheduleRun::query()->count())->toBe(1); + Bus::assertNotDispatched(RunBackupScheduleJob::class); + + $schedule->refresh(); + expect($schedule->next_run_at)->not->toBeNull(); + expect($schedule->next_run_at->toDateTimeString())->toBe('2026-01-06 10:00:00'); +}); diff --git a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php index 276fcf4..807fc5b 100644 --- a/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php +++ b/tests/Feature/BackupScheduling/RunNowRetryActionsTest.php @@ -4,7 +4,9 @@ use App\Jobs\RunBackupScheduleJob; use App\Models\BackupSchedule; use App\Models\BackupScheduleRun; +use App\Models\BulkOperationRun; use App\Models\User; +use Carbon\CarbonImmutable; use Filament\Facades\Filament; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; @@ -38,6 +40,15 @@ $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); expect($run)->not->toBeNull(); + expect($run->user_id)->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'run') + ->count()) + ->toBe(1); Queue::assertPushed(RunBackupScheduleJob::class); @@ -45,6 +56,8 @@ $this->assertDatabaseHas('notifications', [ 'notifiable_id' => $user->id, 'notifiable_type' => User::class, + 'data->format' => 'filament', + 'data->title' => 'Run dispatched', ]); }); @@ -75,8 +88,25 @@ expect(BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count()) ->toBe(1); + $run = BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->first(); + expect($run)->not->toBeNull(); + expect($run->user_id)->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'retry') + ->count()) + ->toBe(1); + Queue::assertPushed(RunBackupScheduleJob::class); $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'data->format' => 'filament', + 'data->title' => 'Retry dispatched', + ]); }); test('readonly cannot dispatch run now or retry', function () { @@ -156,8 +186,24 @@ expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) ->toBe(2); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'run') + ->count()) + ->toBe(1); + Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'data->format' => 'filament', + 'data->title' => 'Runs dispatched', + ]); }); test('operator can bulk retry and it persists a database notification', function () { @@ -200,6 +246,86 @@ expect(BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count()) ->toBe(2); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->value('user_id'))->toBe($user->id); + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->value('user_id'))->toBe($user->id); + + expect(BulkOperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $user->id) + ->where('resource', 'backup_schedule') + ->where('action', 'retry') + ->count()) + ->toBe(1); + Queue::assertPushed(RunBackupScheduleJob::class, 2); $this->assertDatabaseCount('notifications', 1); + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->id, + 'data->format' => 'filament', + 'data->title' => 'Retries dispatched', + ]); +}); + +test('operator can bulk retry even if a run already exists for this minute', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + $scheduleA = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduleB = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly B', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '02:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + ]); + + $scheduledFor = CarbonImmutable::now('UTC')->startOfMinute(); + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $scheduleA->id, + 'tenant_id' => $tenant->id, + 'scheduled_for' => $scheduledFor->toDateTimeString(), + 'status' => BackupScheduleRun::STATUS_RUNNING, + 'summary' => null, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListBackupSchedules::class) + ->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB])); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleA->id)->count()) + ->toBe(2); + + $newRunA = BackupScheduleRun::query() + ->where('backup_schedule_id', $scheduleA->id) + ->orderByDesc('id') + ->first(); + + expect($newRunA)->not->toBeNull(); + expect($newRunA->scheduled_for->setTimezone('UTC')->toDateTimeString()) + ->toBe($scheduledFor->addMinute()->toDateTimeString()); + + expect(BackupScheduleRun::query()->where('backup_schedule_id', $scheduleB->id)->count()) + ->toBe(1); + + Queue::assertPushed(RunBackupScheduleJob::class, 2); }); diff --git a/tests/Feature/BulkProgressNotificationTest.php b/tests/Feature/BulkProgressNotificationTest.php index 19395e6..0f345ec 100644 --- a/tests/Feature/BulkProgressNotificationTest.php +++ b/tests/Feature/BulkProgressNotificationTest.php @@ -1,6 +1,8 @@ $tenant->id, 'user_id' => $user->id, 'status' => 'completed', + 'updated_at' => now()->subMinutes(5), ]); // Other user's op (should not show) @@ -47,3 +50,56 @@ ->assertSee('Delete Policy') ->assertSee('50 / 100'); }); + +test('progress widget reconciles stale pending backup schedule runs', function () { + $tenant = Tenant::factory()->create(); + $tenant->makeCurrent(); + $user = User::factory()->create(); + + $schedule = BackupSchedule::query()->create([ + 'tenant_id' => $tenant->id, + 'name' => 'Nightly', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '01:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'next_run_at' => now()->addHour(), + ]); + + $bulkRun = BulkOperationRun::factory()->create([ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'status' => 'pending', + 'resource' => 'backup_schedule', + 'action' => 'run', + 'total_items' => 1, + 'processed_items' => 0, + 'item_ids' => [(string) $schedule->id], + 'created_at' => now()->subMinutes(2), + 'updated_at' => now()->subMinutes(2), + ]); + + BackupScheduleRun::query()->create([ + 'backup_schedule_id' => $schedule->id, + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'scheduled_for' => now()->startOfMinute(), + 'started_at' => now()->subMinute(), + 'finished_at' => now(), + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => null, + ]); + + auth()->login($user); + + Livewire::actingAs($user) + ->test(BulkOperationProgress::class) + ->assertSee('Run Backup schedule') + ->assertSee('1 / 1'); + + expect($bulkRun->refresh()->status)->toBe('completed'); +}); diff --git a/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php new file mode 100644 index 0000000..40192e5 --- /dev/null +++ b/tests/Feature/Console/PurgeNonPersistentDataCommandTest.php @@ -0,0 +1,143 @@ +create(['name' => 'Tenant A']); + $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); + + SettingsCatalogCategory::create([ + 'category_id' => 'cat-1', + 'display_name' => 'Account Management', + 'description' => null, + ]); + + SettingsCatalogDefinition::create([ + 'definition_id' => 'def-1', + 'display_name' => 'Deletion Policy', + 'description' => null, + 'help_text' => null, + 'category_id' => 'cat-1', + 'ux_behavior' => null, + 'raw' => [], + ]); + + $user = User::factory()->create(); + + $policyA = Policy::factory()->create(['tenant_id' => $tenantA->id]); + $policyB = Policy::factory()->create(['tenant_id' => $tenantB->id]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenantA->id, + 'policy_id' => $policyA->id, + 'version_number' => 1, + ]); + + PolicyVersion::factory()->create([ + 'tenant_id' => $tenantB->id, + 'policy_id' => $policyB->id, + 'version_number' => 1, + ]); + + $backupSetA = BackupSet::factory()->create(['tenant_id' => $tenantA->id]); + BackupItem::factory()->create([ + 'tenant_id' => $tenantA->id, + 'backup_set_id' => $backupSetA->id, + 'policy_id' => $policyA->id, + ]); + + RestoreRun::factory()->create([ + 'tenant_id' => $tenantA->id, + 'backup_set_id' => $backupSetA->id, + ]); + + AuditLog::create([ + 'tenant_id' => $tenantA->id, + 'actor_id' => null, + 'actor_email' => null, + 'actor_name' => null, + 'action' => 'test.action', + 'resource_type' => null, + 'resource_id' => null, + 'status' => 'success', + 'metadata' => null, + 'recorded_at' => now(), + ]); + + BulkOperationRun::factory()->create([ + 'tenant_id' => $tenantA->id, + 'user_id' => $user->id, + 'status' => 'completed', + ]); + + $scheduleA = BackupSchedule::create([ + 'tenant_id' => $tenantA->id, + 'name' => 'Schedule A', + 'is_enabled' => true, + 'timezone' => 'UTC', + 'frequency' => 'daily', + 'time_of_day' => '10:00:00', + 'days_of_week' => null, + 'policy_types' => ['deviceConfiguration'], + 'include_foundations' => true, + 'retention_keep_last' => 30, + 'last_run_at' => null, + 'last_run_status' => null, + 'next_run_at' => now()->addHour(), + ]); + + BackupScheduleRun::create([ + 'backup_schedule_id' => $scheduleA->id, + 'tenant_id' => $tenantA->id, + 'scheduled_for' => now()->startOfMinute(), + 'started_at' => null, + 'finished_at' => null, + 'status' => BackupScheduleRun::STATUS_SUCCESS, + 'summary' => null, + 'error_code' => null, + 'error_message' => null, + 'backup_set_id' => $backupSetA->id, + ]); + + expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0); + + $this->artisan('tenantpilot:purge-nonpersistent', [ + 'tenant' => $tenantA->id, + '--force' => true, + '--no-interaction' => true, + ])->assertSuccessful(); + + expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BackupItem::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + + expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0); + + expect(Policy::query()->where('tenant_id', $tenantB->id)->count())->toBe(1); + expect(PolicyVersion::withTrashed()->where('tenant_id', $tenantB->id)->count())->toBe(1); + + expect(SettingsCatalogCategory::query()->count())->toBe(1); + expect(SettingsCatalogDefinition::query()->count())->toBe(1); +});