diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md new file mode 100644 index 0000000..62118e0 --- /dev/null +++ b/.github/agents/copilot-instructions.md @@ -0,0 +1,29 @@ +# TenantAtlas Development Guidelines + +Auto-generated from all feature plans. Last updated: 2025-12-22 + +## Active Technologies + +- PHP 8.4.15 (feat/005-bulk-operations) + +## Project Structure + +```text +src/ +tests/ +``` + +## Commands + +# Add commands for PHP 8.4.15 + +## Code Style + +PHP 8.4.15: Follow standard conventions + +## Recent Changes + +- feat/005-bulk-operations: Added PHP 8.4.15 + + + diff --git a/README.md b/README.md index e9213f9..85438b8 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ ## TenantPilot setup - `GRAPH_CLIENT_SECRET` - `GRAPH_SCOPE` (default `https://graph.microsoft.com/.default`) - Without these, the `NullGraphClient` runs in dry mode (no Graph calls). + - **Required API Permissions**: See [docs/PERMISSIONS.md](docs/PERMISSIONS.md) for complete list + - **Missing permissions?** Scope tags will show as "Unknown (ID: X)" - add `DeviceManagementRBAC.Read.All` - Deployment (Dokploy, staging → production): - Containerized deploy; ensure Postgres + Redis are provisioned (see `docker-compose.yml` for local baseline). - Run migrations on staging first, validate backup/restore flows, then promote to production. diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index e889d00..e133010 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -197,6 +197,8 @@ public static function createBackupSet(array $data): BackupSet name: $data['name'] ?? null, actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, + includeAssignments: $data['include_assignments'] ?? false, + includeScopeTags: $data['include_scope_tags'] ?? false, ); } } diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index ac66227..fde907c 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -23,6 +23,7 @@ class BackupItemsRelationManager extends RelationManager public function table(Table $table): Table { return $table + ->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion')) ->columns([ Tables\Columns\TextColumn::make('policy.display_name') ->label('Policy') @@ -46,13 +47,41 @@ public function table(Table $table): Table ->label('Policy ID') ->copyable(), Tables\Columns\TextColumn::make('platform')->badge(), + Tables\Columns\TextColumn::make('assignments') + ->label('Assignments') + ->badge() + ->color('info') + ->getStateUsing(function (BackupItem $record): int { + $assignments = $record->policyVersion?->assignments ?? $record->assignments ?? []; + + return is_array($assignments) ? count($assignments) : 0; + }), + Tables\Columns\TextColumn::make('scope_tags') + ->label('Scope Tags') + ->default('—') + ->getStateUsing(function (BackupItem $record): array { + $tags = $record->policyVersion?->scope_tags['names'] ?? []; + + return is_array($tags) ? $tags : []; + }) + ->formatStateUsing(function ($state): string { + if (is_array($state)) { + return $state === [] ? '—' : implode(', ', $state); + } + + if (is_string($state) && $state !== '') { + return $state; + } + + return '—'; + }), Tables\Columns\TextColumn::make('captured_at')->dateTime(), Tables\Columns\TextColumn::make('created_at')->since(), ]) ->filters([]) ->headerActions([ Actions\Action::make('addPolicies') - ->label('Policies hinzufügen') + ->label('Add Policies') ->icon('heroicon-o-plus') ->form([ Forms\Components\Select::make('policy_ids') @@ -70,10 +99,19 @@ public function table(Table $table): Table return Policy::query() ->where('tenant_id', $tenantId) + ->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.'), ]) ->action(function (array $data, BackupService $service) { if (empty($data['policy_ids'])) { @@ -94,6 +132,8 @@ public function table(Table $table): Table policyIds: $data['policy_ids'], actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, + includeAssignments: $data['include_assignments'] ?? false, + includeScopeTags: $data['include_scope_tags'] ?? false, ); Notification::make() diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index c132df3..4ea521f 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -203,6 +203,11 @@ public static function infolist(Schema $schema): Schema public static function table(Table $table): Table { return $table + ->modifyQueryUsing(function (Builder $query) { + // Quick-Workaround: Hide policies not synced in last 7 days + // Full solution in Feature 005: Policy Lifecycle Management (soft delete) + $query->where('last_synced_at', '>', now()->subDays(7)); + }) ->columns([ Tables\Columns\TextColumn::make('display_name') ->label('Policy') diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index ad2188f..17c7b1b 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -5,6 +5,7 @@ use App\Filament\Resources\PolicyResource; use App\Services\Intune\VersionService; use Filament\Actions\Action; +use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; use Filament\Support\Enums\Width; @@ -23,7 +24,17 @@ protected function getActions(): array ->requiresConfirmation() ->modalHeading('Capture snapshot now') ->modalSubheading('This will fetch the latest configuration from Microsoft Graph and store a new policy version.') - ->action(function () { + ->form([ + 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.'), + ]) + ->action(function (array $data) { $policy = $this->record; try { @@ -38,7 +49,13 @@ protected function getActions(): array return; } - app(VersionService::class)->captureFromGraph($tenant, $policy, auth()->user()?->email ?? null); + app(VersionService::class)->captureFromGraph( + tenant: $tenant, + policy: $policy, + createdBy: auth()->user()?->email ?? null, + includeAssignments: $data['include_assignments'] ?? false, + includeScopeTags: $data['include_scope_tags'] ?? false, + ); Notification::make() ->title('Snapshot captured successfully.') diff --git a/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php b/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php index 83ac3d1..291192c 100644 --- a/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php +++ b/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php @@ -5,10 +5,18 @@ use App\Filament\Resources\PolicyVersionResource; use Filament\Resources\Pages\ViewRecord; use Filament\Support\Enums\Width; +use Illuminate\Contracts\View\View; class ViewPolicyVersion extends ViewRecord { protected static string $resource = PolicyVersionResource::class; protected Width|string|null $maxContentWidth = Width::Full; + + public function getFooter(): ?View + { + return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [ + 'record' => $this->getRecord(), + ]); + } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index bad0ae9..95ab893 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -7,6 +7,8 @@ use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\Graph\GraphClientInterface; +use App\Services\Graph\GroupResolver; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; @@ -15,10 +17,13 @@ use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Support\Str; use UnitEnum; class RestoreRunResource extends Resource @@ -54,6 +59,10 @@ public static function form(Schema $schema): Schema }); }) ->reactive() + ->afterStateUpdated(function (Set $set): void { + $set('backup_item_ids', []); + $set('group_mapping', []); + }) ->required(), Forms\Components\CheckboxList::make('backup_item_ids') ->label('Items to restore (optional)') @@ -86,7 +95,57 @@ public static function form(Schema $schema): Schema }); }) ->columns(2) + ->reactive() + ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->helperText('Preview-only types stay in dry-run; leave empty to include all items.'), + 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'); + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return []; + } + + $unresolved = static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: is_array($selectedItemIds) ? $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 ($value) => static::resolveTargetGroupLabel($tenant, $value)) + ->required() + ->helperText('Choose a target group or select Skip.'); + }, $unresolved); + }) + ->visible(function (Get $get): bool { + $backupSetId = $get('backup_set_id'); + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return false; + } + + return static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, + tenant: $tenant + ) !== []; + }), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true), @@ -233,6 +292,161 @@ public static function createRestoreRun(array $data): RestoreRun dryRun: (bool) ($data['is_dry_run'] ?? true), actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, + groupMapping: $data['group_mapping'] ?? [], ); } + + /** + * @param array|null $selectedItemIds + * @return array + */ + private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array + { + if (! $backupSetId) { + return []; + } + + $query = BackupItem::query()->where('backup_set_id', $backupSetId); + + if ($selectedItemIds !== null) { + $query->whereIn('id', $selectedItemIds); + } + + $items = $query->get(['assignments']); + $assignments = []; + $sourceNames = []; + + foreach ($items 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; + } + + $assignments[] = $groupId; + $displayName = $target['group_display_name'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + $sourceNames[$groupId] = $displayName; + } + } + } + + $groupIds = array_values(array_unique($assignments)); + + if ($groupIds === []) { + return []; + } + + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + $resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + + $unresolved = []; + + foreach ($groupIds as $groupId) { + $group = $resolved[$groupId] ?? null; + + if (! is_array($group) || ! ($group['orphaned'] ?? false)) { + continue; + } + + $label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId); + $unresolved[] = [ + 'id' => $groupId, + 'label' => $label, + ]; + } + + return $unresolved; + } + + /** + * @return array + */ + private static function targetGroupOptions(Tenant $tenant, string $search): array + { + if (mb_strlen($search) < 2) { + return []; + } + + try { + $response = app(GraphClientInterface::class)->request( + 'GET', + 'groups', + [ + 'query' => [ + '$filter' => sprintf( + "securityEnabled eq true and startswith(displayName,'%s')", + static::escapeOdataValue($search) + ), + '$select' => 'id,displayName', + '$top' => 20, + ], + ] + $tenant->graphOptions() + ); + } catch (\Throwable) { + return []; + } + + if ($response->failed()) { + return []; + } + + return collect($response->data['value'] ?? []) + ->filter(fn (array $group) => filled($group['id'] ?? null)) + ->mapWithKeys(fn (array $group) => [ + $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']), + ]) + ->all(); + } + + private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string + { + if (! $groupId) { + return $groupId; + } + + if ($groupId === 'SKIP') { + return 'Skip assignment'; + } + + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + $resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions); + $group = $resolved[$groupId] ?? null; + + return static::formatGroupLabel($group['displayName'] ?? null, $groupId); + } + + private static function formatGroupLabel(?string $displayName, string $id): string + { + $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); + + return trim(($displayName ?: 'Security group').$suffix); + } + + private static function escapeOdataValue(string $value): string + { + return str_replace("'", "''", $value); + } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index 567b830..40a635c 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -248,12 +248,37 @@ public static function infolist(Schema $schema): Schema Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(), Infolists\Components\TextEntry::make('domain')->copyable(), Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(), - Infolists\Components\TextEntry::make('status')->badge(), - Infolists\Components\TextEntry::make('app_status')->badge(), + Infolists\Components\TextEntry::make('status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'active' => 'success', + 'inactive' => 'gray', + 'suspended' => 'warning', + 'error' => 'danger', + default => 'gray', + }), + Infolists\Components\TextEntry::make('app_status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'ok', 'configured' => 'success', + 'pending' => 'warning', + 'error' => 'danger', + 'requires_consent' => 'warning', + default => 'gray', + }), Infolists\Components\TextEntry::make('app_notes')->label('Notes'), Infolists\Components\TextEntry::make('created_at')->dateTime(), Infolists\Components\TextEntry::make('updated_at')->dateTime(), - Infolists\Components\TextEntry::make('rbac_status')->label('RBAC status')->badge(), + Infolists\Components\TextEntry::make('rbac_status') + ->label('RBAC status') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'ok', 'configured' => 'success', + 'manual_assignment_required' => 'warning', + 'error', 'failed' => 'danger', + 'not_configured' => 'gray', + default => 'gray', + }), Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'), Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(), Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'), @@ -281,7 +306,13 @@ public static function infolist(Schema $schema): Schema ->label('Features') ->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state), Infolists\Components\TextEntry::make('status') - ->badge(), + ->badge() + ->color(fn (string $state): string => match ($state) { + 'granted' => 'success', + 'missing' => 'warning', + 'error' => 'danger', + default => 'gray', + }), ]) ->columnSpanFull(), ]); @@ -908,7 +939,7 @@ public static function verifyTenant( actorEmail: $user?->email, actorName: $user?->name, status: match ($permissions['overall_status']) { - 'ok' => 'success', + 'granted' => 'success', 'error' => 'error', default => 'partial', }, diff --git a/app/Jobs/FetchAssignmentsJob.php b/app/Jobs/FetchAssignmentsJob.php new file mode 100644 index 0000000..7bb8d89 --- /dev/null +++ b/app/Jobs/FetchAssignmentsJob.php @@ -0,0 +1,87 @@ +backupItemId); + + if ($backupItem === null) { + Log::warning('FetchAssignmentsJob: BackupItem not found', [ + 'backup_item_id' => $this->backupItemId, + ]); + + return; + } + + // Only process Settings Catalog policies + if ($backupItem->policy_type !== 'settingsCatalogPolicy') { + Log::info('FetchAssignmentsJob: Skipping non-Settings Catalog policy', [ + 'backup_item_id' => $this->backupItemId, + 'policy_type' => $backupItem->policy_type, + ]); + + return; + } + + $assignmentBackupService->enrichWithAssignments( + backupItem: $backupItem, + tenantId: $this->tenantExternalId, + policyId: $this->policyExternalId, + policyPayload: $this->policyPayload, + includeAssignments: true + ); + + Log::info('FetchAssignmentsJob: Successfully enriched BackupItem', [ + 'backup_item_id' => $this->backupItemId, + 'assignment_count' => $backupItem->getAssignmentCount(), + ]); + } catch (\Throwable $e) { + Log::error('FetchAssignmentsJob: Failed to enrich BackupItem', [ + 'backup_item_id' => $this->backupItemId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Don't retry - fail soft + $this->fail($e); + } + } +} diff --git a/app/Jobs/RestoreAssignmentsJob.php b/app/Jobs/RestoreAssignmentsJob.php new file mode 100644 index 0000000..5bdec75 --- /dev/null +++ b/app/Jobs/RestoreAssignmentsJob.php @@ -0,0 +1,84 @@ +restoreRunId); + $tenant = Tenant::find($this->tenantId); + + if (! $restoreRun || ! $tenant) { + Log::warning('RestoreAssignmentsJob missing context', [ + 'restore_run_id' => $this->restoreRunId, + 'tenant_id' => $this->tenantId, + ]); + + return [ + 'outcomes' => [], + 'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0], + ]; + } + + try { + return $assignmentRestoreService->restore( + tenant: $tenant, + policyType: $this->policyType, + policyId: $this->policyId, + assignments: $this->assignments, + groupMapping: $this->groupMapping, + restoreRun: $restoreRun, + actorEmail: $this->actorEmail, + actorName: $this->actorName, + ); + } catch (\Throwable $e) { + Log::error('RestoreAssignmentsJob failed', [ + 'restore_run_id' => $this->restoreRunId, + 'policy_id' => $this->policyId, + 'error' => $e->getMessage(), + ]); + + return [ + 'outcomes' => [[ + 'status' => 'failed', + 'reason' => $e->getMessage(), + ]], + 'summary' => ['success' => 0, 'failed' => 1, 'skipped' => 0], + ]; + } + } +} diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php new file mode 100644 index 0000000..6211e6c --- /dev/null +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -0,0 +1,23 @@ +version = $version; + } + + public function render() + { + return view('livewire.policy-version-assignments-widget', [ + 'version' => $this->version, + ]); + } +} diff --git a/app/Models/BackupItem.php b/app/Models/BackupItem.php index dde4183..9f9ad2c 100644 --- a/app/Models/BackupItem.php +++ b/app/Models/BackupItem.php @@ -19,6 +19,7 @@ class BackupItem extends Model protected $casts = [ 'payload' => 'array', 'metadata' => 'array', + 'assignments' => 'array', 'captured_at' => 'datetime', ]; @@ -36,4 +37,57 @@ public function policy(): BelongsTo { return $this->belongsTo(Policy::class); } + + public function policyVersion(): BelongsTo + { + return $this->belongsTo(PolicyVersion::class); + } + + // Assignment helpers + public function getAssignmentCountAttribute(): int + { + return count($this->assignments ?? []); + } + + public function hasAssignments(): bool + { + return ! empty($this->assignments); + } + + public function getGroupIdsAttribute(): array + { + return collect($this->assignments ?? []) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + } + + public function getScopeTagIdsAttribute(): array + { + return $this->metadata['scope_tag_ids'] ?? ['0']; + } + + public function getScopeTagNamesAttribute(): array + { + return $this->metadata['scope_tag_names'] ?? ['Default']; + } + + public function hasOrphanedAssignments(): bool + { + return $this->metadata['has_orphaned_assignments'] ?? false; + } + + public function assignmentsFetchFailed(): bool + { + return $this->metadata['assignments_fetch_failed'] ?? false; + } + + // Scopes + public function scopeWithAssignments($query) + { + return $query->whereNotNull('assignments') + ->whereRaw('json_array_length(assignments) > 0'); + } } diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php index ef8ef6e..ae2555e 100644 --- a/app/Models/PolicyVersion.php +++ b/app/Models/PolicyVersion.php @@ -17,6 +17,8 @@ class PolicyVersion extends Model protected $casts = [ 'snapshot' => 'array', 'metadata' => 'array', + 'assignments' => 'array', + 'scope_tags' => 'array', 'captured_at' => 'datetime', ]; diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index cd07138..b729987 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -20,6 +20,7 @@ class RestoreRun extends Model 'preview' => 'array', 'results' => 'array', 'metadata' => 'array', + 'group_mapping' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime', ]; @@ -33,4 +34,81 @@ public function backupSet(): BelongsTo { return $this->belongsTo(BackupSet::class); } + + // Group mapping helpers + public function hasGroupMapping(): bool + { + return ! empty($this->group_mapping); + } + + public function getMappedGroupId(string $sourceGroupId): ?string + { + $mapping = $this->group_mapping ?? []; + + return $mapping[$sourceGroupId] ?? null; + } + + public function isGroupSkipped(string $sourceGroupId): bool + { + $mapping = $this->group_mapping ?? []; + + return ($mapping[$sourceGroupId] ?? null) === 'SKIP'; + } + + public function getUnmappedGroupIds(array $sourceGroupIds): array + { + return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? [])); + } + + public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void + { + $mapping = $this->group_mapping ?? []; + $mapping[$sourceGroupId] = $targetGroupId; + $this->group_mapping = $mapping; + } + + // Assignment restore outcome helpers + public function getAssignmentRestoreOutcomes(): array + { + $results = $this->results ?? []; + + if (isset($results['assignment_outcomes']) && is_array($results['assignment_outcomes'])) { + return $results['assignment_outcomes']; + } + + if (! is_array($results)) { + return []; + } + + return collect($results) + ->pluck('assignment_outcomes') + ->flatten(1) + ->filter() + ->values() + ->all(); + } + + public function getSuccessfulAssignmentsCount(): int + { + return count(array_filter( + $this->getAssignmentRestoreOutcomes(), + fn ($outcome) => $outcome['status'] === 'success' + )); + } + + public function getFailedAssignmentsCount(): int + { + return count(array_filter( + $this->getAssignmentRestoreOutcomes(), + fn ($outcome) => $outcome['status'] === 'failed' + )); + } + + public function getSkippedAssignmentsCount(): int + { + return count(array_filter( + $this->getAssignmentRestoreOutcomes(), + fn ($outcome) => $outcome['status'] === 'skipped' + )); + } } diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php new file mode 100644 index 0000000..f94c697 --- /dev/null +++ b/app/Services/AssignmentBackupService.php @@ -0,0 +1,194 @@ +resolveScopeTagNames($scopeTagIds, $tenant); + + $metadata = $backupItem->metadata ?? []; + $metadata['scope_tag_ids'] = $scopeTagIds; + $metadata['scope_tag_names'] = $scopeTagNames; + + // Only fetch assignments if explicitly requested + if (! $includeAssignments) { + $metadata['assignment_count'] = 0; + $backupItem->update([ + 'assignments' => null, + 'metadata' => $metadata, + ]); + + return $backupItem->refresh(); + } + + // Fetch assignments from Graph API + $graphOptions = $tenant->graphOptions(); + $tenantId = $graphOptions['tenant'] ?? $tenant->external_id ?? $tenant->tenant_id; + $assignments = $this->assignmentFetcher->fetch($tenantId, $policyId, $graphOptions); + + if (empty($assignments)) { + // No assignments or fetch failed + $metadata['assignment_count'] = 0; + $metadata['assignments_fetch_failed'] = true; + $metadata['has_orphaned_assignments'] = false; + + $backupItem->update([ + 'assignments' => [], // Return empty array instead of null + 'metadata' => $metadata, + ]); + + Log::warning('No assignments fetched for policy', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + 'backup_item_id' => $backupItem->id, + ]); + + return $backupItem->refresh(); + } + + // Extract group IDs and resolve for orphan detection + $groupIds = $this->extractGroupIds($assignments); + $resolvedGroups = []; + $hasOrphanedGroups = false; + + if (! empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantId, $graphOptions); + $hasOrphanedGroups = collect($resolvedGroups) + ->contains(fn (array $group) => $group['orphaned'] ?? false); + } + + $filterIds = collect($assignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($assignments, $resolvedGroups, $filterNames); + + // Update backup item with assignments and metadata + $metadata['assignment_count'] = count($assignments); + $metadata['assignments_fetch_failed'] = false; + $metadata['has_orphaned_assignments'] = $hasOrphanedGroups; + + $backupItem->update([ + 'assignments' => $assignments, + 'metadata' => $metadata, + ]); + + Log::info('Assignments enriched for backup item', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + 'backup_item_id' => $backupItem->id, + 'assignment_count' => count($assignments), + 'has_orphaned' => $hasOrphanedGroups, + ]); + + return $backupItem->refresh(); + } + + /** + * Resolve scope tag IDs to display names. + */ + private function resolveScopeTagNames(array $scopeTagIds, Tenant $tenant): array + { + $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant); + + $names = []; + foreach ($scopeTagIds as $id) { + $scopeTag = collect($scopeTags)->firstWhere('id', $id); + $names[] = $scopeTag['displayName'] ?? "Unknown (ID: {$id})"; + } + + return $names; + } + + /** + * Extract group IDs from assignment array. + */ + private function extractGroupIds(array $assignments): array + { + $groupIds = []; + + foreach ($assignments as $assignment) { + $target = $assignment['target'] ?? []; + $odataType = $target['@odata.type'] ?? ''; + + if (in_array($odataType, [ + '#microsoft.graph.groupAssignmentTarget', + '#microsoft.graph.exclusionGroupAssignmentTarget', + ], true) && isset($target['groupId'])) { + $groupIds[] = $target['groupId']; + } + } + + return array_unique($groupIds); + } + + /** + * @param array> $assignments + * @param array $groups + * @param array $filterNames + * @return array> + */ + private function enrichAssignments(array $assignments, array $groups, array $filterNames): array + { + return array_map(function (array $assignment) use ($groups, $filterNames): array { + $target = $assignment['target'] ?? []; + $groupId = $target['groupId'] ?? null; + + if ($groupId && isset($groups[$groupId])) { + $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; + $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; + } + + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + if ($filterId && isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } + + $assignment['target'] = $target; + + return $assignment; + }, $assignments); + } +} diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php new file mode 100644 index 0000000..1505be7 --- /dev/null +++ b/app/Services/AssignmentRestoreService.php @@ -0,0 +1,466 @@ +> $assignments + * @param array $groupMapping + * @return array{outcomes: array>, summary: array{success:int,failed:int,skipped:int}} + */ + public function restore( + Tenant $tenant, + string $policyType, + string $policyId, + array $assignments, + array $groupMapping, + ?RestoreRun $restoreRun = null, + ?string $actorEmail = null, + ?string $actorName = null, + ): array { + $outcomes = []; + $summary = [ + 'success' => 0, + 'failed' => 0, + 'skipped' => 0, + ]; + + if ($assignments === []) { + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + $contract = $this->contracts->get($policyType); + $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); + $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); + $usesAssignAction = is_string($createPath) && str_ends_with($createPath, '/assign'); + $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); + $deletePathTemplate = $contract['assignments_delete_path'] ?? null; + + if (! $createPath || (! $usesAssignAction && (! $listPath || ! $deletePathTemplate))) { + $outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.'); + $summary['failed']++; + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + $context = [ + 'tenant' => $tenantIdentifier, + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'restore_run_id' => $restoreRun?->id, + ]; + + $preparedAssignments = []; + $preparedMeta = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $groupId = $assignment['target']['groupId'] ?? null; + $mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null; + + if ($mappedGroupId === 'SKIP') { + $outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId); + $summary['skipped']++; + $this->logAssignmentOutcome( + status: 'skipped', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ] + ); + + continue; + } + + $assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId); + $assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore); + + $preparedAssignments[] = $assignmentToRestore; + $preparedMeta[] = [ + 'assignment' => $assignment, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]; + } + + if ($preparedAssignments === []) { + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + if ($usesAssignAction) { + $this->graphLogger->logRequest('restore_assignments_assign', $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'assignments' => count($preparedAssignments), + ]); + + $assignResponse = $this->graphClient->request($createMethod, $createPath, [ + 'json' => ['assignments' => $preparedAssignments], + ] + $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_assign', $assignResponse, $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'assignments' => count($preparedAssignments), + ]); + + if ($assignResponse->successful()) { + foreach ($preparedMeta as $meta) { + $outcomes[] = $this->successOutcome( + $meta['assignment'], + $meta['group_id'], + $meta['mapped_group_id'] + ); + $summary['success']++; + $this->logAssignmentOutcome( + status: 'created', + tenant: $tenant, + assignment: $meta['assignment'], + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + ] + ); + } + } else { + $reason = $assignResponse->meta['error_message'] ?? 'Graph assign failed'; + + if ($preparedMeta === []) { + $outcomes[] = $this->failureOutcome(null, $reason, null, null, $assignResponse); + $summary['failed']++; + } + + foreach ($preparedMeta as $meta) { + $outcomes[] = $this->failureOutcome( + $meta['assignment'], + $reason, + $meta['group_id'], + $meta['mapped_group_id'], + $assignResponse + ); + $summary['failed']++; + $this->logAssignmentOutcome( + status: 'failed', + tenant: $tenant, + assignment: $meta['assignment'], + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + 'graph_error_message' => $assignResponse->meta['error_message'] ?? null, + 'graph_error_code' => $assignResponse->meta['error_code'] ?? null, + ], + ); + } + } + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + $this->graphLogger->logRequest('restore_assignments_list', $context + [ + 'method' => 'GET', + 'endpoint' => $listPath, + ]); + + $response = $this->graphClient->request('GET', $listPath, $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_list', $response, $context + [ + 'method' => 'GET', + 'endpoint' => $listPath, + ]); + + $existingAssignments = $response->data['value'] ?? []; + + foreach ($existingAssignments as $existing) { + $assignmentId = $existing['id'] ?? null; + + if (! is_string($assignmentId) || $assignmentId === '') { + continue; + } + + $deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId); + + if (! $deletePath) { + continue; + } + + $this->graphLogger->logRequest('restore_assignments_delete', $context + [ + 'method' => 'DELETE', + 'endpoint' => $deletePath, + 'assignment_id' => $assignmentId, + ]); + + $deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [ + 'method' => 'DELETE', + 'endpoint' => $deletePath, + 'assignment_id' => $assignmentId, + ]); + + if ($deleteResponse->failed()) { + Log::warning('Failed to delete existing assignment during restore', $context + [ + 'assignment_id' => $assignmentId, + 'graph_error_message' => $deleteResponse->meta['error_message'] ?? null, + 'graph_error_code' => $deleteResponse->meta['error_code'] ?? null, + ]); + } + } + + foreach ($preparedMeta as $index => $meta) { + $assignmentToRestore = $preparedAssignments[$index] ?? null; + + if (! is_array($assignmentToRestore)) { + continue; + } + + $this->graphLogger->logRequest('restore_assignments_create', $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + ]); + + $createResponse = $this->graphClient->request($createMethod, $createPath, [ + 'json' => $assignmentToRestore, + ] + $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + ]); + + if ($createResponse->successful()) { + $outcomes[] = $this->successOutcome($meta['assignment'], $meta['group_id'], $meta['mapped_group_id']); + $summary['success']++; + $this->logAssignmentOutcome( + status: 'created', + tenant: $tenant, + assignment: $meta['assignment'], + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + ] + ); + } else { + $outcomes[] = $this->failureOutcome( + $meta['assignment'], + $createResponse->meta['error_message'] ?? 'Graph create failed', + $meta['group_id'], + $meta['mapped_group_id'], + $createResponse + ); + $summary['failed']++; + $this->logAssignmentOutcome( + status: 'failed', + tenant: $tenant, + assignment: $meta['assignment'], + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $meta['group_id'], + 'mapped_group_id' => $meta['mapped_group_id'], + 'graph_error_message' => $createResponse->meta['error_message'] ?? null, + 'graph_error_code' => $createResponse->meta['error_code'] ?? null, + ], + ); + } + + usleep(100000); + } + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string + { + if (! is_string($template) || $template === '') { + return null; + } + + $path = str_replace('{id}', urlencode($policyId), $template); + + if ($assignmentId !== null) { + $path = str_replace('{assignmentId}', urlencode($assignmentId), $path); + } + + return $path; + } + + private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array + { + if (! $mappedGroupId) { + return $assignment; + } + + $target = $assignment['target'] ?? []; + $odataType = $target['@odata.type'] ?? ''; + + if (in_array($odataType, [ + '#microsoft.graph.groupAssignmentTarget', + '#microsoft.graph.exclusionGroupAssignmentTarget', + ], true) && isset($target['groupId'])) { + $target['groupId'] = $mappedGroupId; + $assignment['target'] = $target; + } + + return $assignment; + } + + private function sanitizeAssignment(array $assignment): array + { + $assignment = Arr::except($assignment, ['id']); + $target = $assignment['target'] ?? []; + + unset( + $target['group_display_name'], + $target['group_orphaned'], + $target['assignment_filter_name'] + ); + + $assignment['target'] = $target; + + return $assignment; + } + + private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array + { + return [ + 'status' => 'success', + 'assignment' => $this->sanitizeAssignment($assignment), + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]; + } + + private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array + { + return [ + 'status' => 'skipped', + 'assignment' => $this->sanitizeAssignment($assignment), + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]; + } + + private function failureOutcome( + ?array $assignment, + string $reason, + ?string $groupId = null, + ?string $mappedGroupId = null, + ?GraphResponse $response = null + ): array { + return array_filter([ + 'status' => 'failed', + 'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + 'reason' => $reason, + 'graph_error_message' => $response?->meta['error_message'] ?? null, + '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, + ], static fn ($value) => $value !== null); + } + + private function logAssignmentOutcome( + string $status, + Tenant $tenant, + array $assignment, + ?RestoreRun $restoreRun, + ?string $actorEmail, + ?string $actorName, + array $metadata + ): void { + $action = match ($status) { + 'created' => 'restore.assignment.created', + 'failed' => 'restore.assignment.failed', + default => 'restore.assignment.skipped', + }; + + $statusLabel = match ($status) { + 'created' => 'success', + 'failed' => 'failed', + default => 'warning', + }; + + $this->auditLogger->log( + tenant: $tenant, + action: $action, + context: [ + 'metadata' => $metadata, + 'assignment' => $this->sanitizeAssignment($assignment), + ], + actorEmail: $actorEmail, + actorName: $actorName, + status: $statusLabel, + resourceType: 'restore_run', + resourceId: $restoreRun ? (string) $restoreRun->id : null + ); + } +} diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php new file mode 100644 index 0000000..53d3ec6 --- /dev/null +++ b/app/Services/Graph/AssignmentFetcher.php @@ -0,0 +1,111 @@ + $tenantId]); + + // Try primary endpoint + $assignments = $this->fetchPrimary($policyId, $requestOptions); + + if (! empty($assignments)) { + Log::debug('Fetched assignments via primary endpoint', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); + + return $assignments; + } + + // Try fallback with $expand + Log::debug('Primary endpoint returned empty, trying fallback', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + ]); + + $assignments = $this->fetchWithExpand($policyId, $requestOptions); + + if (! empty($assignments)) { + Log::debug('Fetched assignments via fallback endpoint', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + 'count' => count($assignments), + ]); + + return $assignments; + } + + // Both methods returned empty + Log::debug('No assignments found for policy', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + ]); + + return []; + } catch (GraphException $e) { + Log::warning('Failed to fetch assignments', [ + 'tenant_id' => $tenantId, + 'policy_id' => $policyId, + 'error' => $e->getMessage(), + 'context' => $e->context, + ]); + + return []; + } + } + + /** + * Fetch assignments using primary endpoint. + */ + private function fetchPrimary(string $policyId, array $options): array + { + $path = "/deviceManagement/configurationPolicies/{$policyId}/assignments"; + + $response = $this->graphClient->request('GET', $path, $options); + + return $response->data['value'] ?? []; + } + + /** + * Fetch assignments using $expand fallback. + */ + private function fetchWithExpand(string $policyId, array $options): array + { + $path = '/deviceManagement/configurationPolicies'; + $params = [ + '$expand' => 'assignments', + '$filter' => "id eq '{$policyId}'", + ]; + + $response = $this->graphClient->request('GET', $path, array_merge($options, [ + 'query' => $params, + ])); + + $policies = $response->data['value'] ?? []; + + if (empty($policies)) { + return []; + } + + return $policies[0]['assignments'] ?? []; + } +} diff --git a/app/Services/Graph/AssignmentFilterResolver.php b/app/Services/Graph/AssignmentFilterResolver.php new file mode 100644 index 0000000..ab7b0eb --- /dev/null +++ b/app/Services/Graph/AssignmentFilterResolver.php @@ -0,0 +1,54 @@ + $filterIds + * @return array + */ + public function resolve(array $filterIds, ?Tenant $tenant = null): array + { + if (empty($filterIds)) { + return []; + } + + $allFilters = $this->fetchAllFilters($tenant); + + return array_values(array_filter($allFilters, function (array $filter) use ($filterIds): bool { + return in_array($filter['id'] ?? null, $filterIds, true); + })); + } + + /** + * @return array + */ + private function fetchAllFilters(?Tenant $tenant = null): array + { + $cacheKey = $tenant ? "assignment_filters:tenant:{$tenant->id}" : 'assignment_filters:all'; + + return Cache::remember($cacheKey, 3600, function () use ($tenant): array { + $options = ['query' => ['$select' => 'id,displayName']]; + + if ($tenant) { + $options = array_merge($options, $tenant->graphOptions()); + } + + $response = $this->graphClient->request( + 'GET', + '/deviceManagement/assignmentFilters', + $options + ); + + return $response->data['value'] ?? []; + }); + } +} diff --git a/app/Services/Graph/GroupResolver.php b/app/Services/Graph/GroupResolver.php new file mode 100644 index 0000000..afac6a4 --- /dev/null +++ b/app/Services/Graph/GroupResolver.php @@ -0,0 +1,123 @@ + ['id' => ..., 'displayName' => ..., 'orphaned' => bool]] + */ + public function resolveGroupIds(array $groupIds, string $tenantId, array $options = []): array + { + if (empty($groupIds)) { + return []; + } + + // Create cache key + $cacheKey = $this->getCacheKey($groupIds, $tenantId); + + return Cache::remember($cacheKey, 300, function () use ($groupIds, $tenantId, $options) { + return $this->fetchAndResolveGroups($groupIds, $tenantId, $options); + }); + } + + /** + * Fetch groups from Graph API and resolve orphaned IDs. + */ + private function fetchAndResolveGroups(array $groupIds, string $tenantId, array $options = []): array + { + try { + $response = $this->graphClient->request( + 'POST', + '/directoryObjects/getByIds', + array_merge($options, [ + 'tenant' => $tenantId, + 'json' => [ + 'ids' => array_values($groupIds), + 'types' => ['group'], + ], + ]) + ); + + $resolvedGroups = $response->data['value'] ?? []; + + // Create result map + $result = []; + $resolvedIds = []; + + // Add resolved groups + foreach ($resolvedGroups as $group) { + $groupId = $group['id']; + $resolvedIds[] = $groupId; + $result[$groupId] = [ + 'id' => $groupId, + 'displayName' => $group['displayName'] ?? null, + 'orphaned' => false, + ]; + } + + // Add orphaned groups (not in response) + foreach ($groupIds as $groupId) { + if (! in_array($groupId, $resolvedIds)) { + $result[$groupId] = [ + 'id' => $groupId, + 'displayName' => null, + 'orphaned' => true, + ]; + } + } + + Log::debug('Resolved group IDs', [ + 'tenant_id' => $tenantId, + 'requested' => count($groupIds), + 'resolved' => count($resolvedIds), + 'orphaned' => count($groupIds) - count($resolvedIds), + ]); + + return $result; + } catch (GraphException $e) { + Log::warning('Failed to resolve group IDs', [ + 'tenant_id' => $tenantId, + 'group_ids' => $groupIds, + 'error' => $e->getMessage(), + 'context' => $e->context, + ]); + + // Return all as orphaned on failure + $result = []; + foreach ($groupIds as $groupId) { + $result[$groupId] = [ + 'id' => $groupId, + 'displayName' => null, + 'orphaned' => true, + ]; + } + + return $result; + } + } + + /** + * Generate cache key for group resolution. + */ + private function getCacheKey(array $groupIds, string $tenantId): string + { + sort($groupIds); + + return "groups:{$tenantId}:".md5(implode(',', $groupIds)); + } +} diff --git a/app/Services/Graph/ScopeTagResolver.php b/app/Services/Graph/ScopeTagResolver.php new file mode 100644 index 0000000..0460eeb --- /dev/null +++ b/app/Services/Graph/ScopeTagResolver.php @@ -0,0 +1,89 @@ +fetchAllScopeTags($tenant); + + // Filter to requested IDs + return array_filter($allScopeTags, function ($scopeTag) use ($scopeTagIds) { + return in_array($scopeTag['id'], $scopeTagIds); + }); + } + + /** + * Fetch all scope tags from Graph API (cached for 1 hour). + */ + private function fetchAllScopeTags(?Tenant $tenant = null): array + { + $cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all'; + + return Cache::remember($cacheKey, 3600, function () use ($tenant) { + try { + $options = ['query' => ['$select' => 'id,displayName']]; + + // Add tenant credentials if provided + if ($tenant) { + $options['tenant'] = $tenant->external_id ?? $tenant->tenant_id; + $options['client_id'] = $tenant->app_client_id; + $options['client_secret'] = $tenant->app_client_secret; + } + + $graphResponse = $this->graphClient->request( + 'GET', + '/deviceManagement/roleScopeTags', + $options + ); + + $scopeTags = $graphResponse->data['value'] ?? []; + + // Check for 403 Forbidden (missing permissions) + if (! $graphResponse->success && $graphResponse->status === 403) { + \Log::warning('Scope tag fetch failed: Missing permissions', [ + 'tenant_id' => $tenant?->id, + 'status' => 403, + 'required_permissions' => ['DeviceManagementRBAC.Read.All', 'DeviceManagementRBAC.ReadWrite.All'], + 'message' => 'App registration needs DeviceManagementRBAC permissions to read scope tags', + ]); + } + + // Success - return scope tags + return $scopeTags; + } catch (\Exception $e) { + // Fail soft - return empty array on any error + \Log::warning('Scope tag fetch exception', [ + 'tenant_id' => $tenant?->id, + 'error' => $e->getMessage(), + ]); + + return []; + } + }); + } +} diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index a3109b2..dd8aee4 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -6,6 +6,7 @@ use App\Models\BackupSet; use App\Models\Policy; use App\Models\Tenant; +use App\Services\AssignmentBackupService; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\DB; @@ -16,6 +17,8 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly PolicySnapshotService $snapshotService, + private readonly AssignmentBackupService $assignmentBackupService, + private readonly PolicyCaptureOrchestrator $captureOrchestrator, ) {} /** @@ -29,6 +32,8 @@ public function createBackupSet( ?string $actorEmail = null, ?string $actorName = null, ?string $name = null, + bool $includeAssignments = false, + bool $includeScopeTags = false, ): BackupSet { $this->assertActiveTenant($tenant); @@ -37,7 +42,7 @@ public function createBackupSet( ->whereIn('id', $policyIds) ->get(); - $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name) { + $backupSet = DB::transaction(function () use ($tenant, $policies, $actorEmail, $name, $includeAssignments, $includeScopeTags) { $backupSet = BackupSet::create([ 'tenant_id' => $tenant->id, 'name' => $name ?? CarbonImmutable::now()->format('Y-m-d H:i:s').' backup', @@ -50,7 +55,14 @@ public function createBackupSet( $itemsCreated = 0; foreach ($policies as $policy) { - [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); + [$item, $failure] = $this->snapshotPolicy( + $tenant, + $backupSet, + $policy, + $actorEmail, + $includeAssignments, + $includeScopeTags + ); if ($failure !== null) { $failures[] = $failure; @@ -92,6 +104,31 @@ public function createBackupSet( status: $backupSet->status === 'completed' ? 'success' : 'partial' ); + // Log if assignments were included + if ($includeAssignments) { + $items = $backupSet->items; + $assignmentCount = $items->sum(function ($item) { + return $item->metadata['assignment_count'] ?? 0; + }); + + $this->auditLogger->log( + tenant: $tenant, + action: 'backup.assignments.included', + context: [ + 'metadata' => [ + 'backup_set_id' => $backupSet->id, + 'policy_count' => $backupSet->item_count, + 'assignment_count' => $assignmentCount, + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'backup_set', + resourceId: (string) $backupSet->id, + status: 'success' + ); + } + return $backupSet; } @@ -106,6 +143,8 @@ public function addPoliciesToSet( array $policyIds, ?string $actorEmail = null, ?string $actorName = null, + bool $includeAssignments = false, + bool $includeScopeTags = false, ): BackupSet { $this->assertActiveTenant($tenant); @@ -114,9 +153,20 @@ public function addPoliciesToSet( } $existingPolicyIds = $backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); + + // Separate into truly new policies and soft-deleted ones to restore + $softDeletedItems = $backupSet->items()->onlyTrashed()->whereIn('policy_id', $policyIds)->get(); + $softDeletedPolicyIds = $softDeletedItems->pluck('policy_id')->all(); + + // Restore soft-deleted items + foreach ($softDeletedItems as $item) { + $item->restore(); + } + + // Only create new items for policies that don't exist at all $policyIds = array_values(array_diff($policyIds, $existingPolicyIds)); - if (empty($policyIds)) { + if (empty($policyIds) && $softDeletedItems->isEmpty()) { return $backupSet->refresh(); } @@ -130,7 +180,14 @@ public function addPoliciesToSet( $itemsCreated = 0; foreach ($policies as $policy) { - [$item, $failure] = $this->snapshotPolicy($tenant, $backupSet, $policy, $actorEmail); + [$item, $failure] = $this->snapshotPolicy( + $tenant, + $backupSet, + $policy, + $actorEmail, + $includeAssignments, + $includeScopeTags + ); if ($failure !== null) { $failures[] = $failure; @@ -159,6 +216,7 @@ public function addPoliciesToSet( 'metadata' => [ 'backup_set_id' => $backupSet->id, 'added_count' => $itemsCreated, + 'restored_count' => $softDeletedItems->count(), 'status' => $status, ], ], @@ -184,18 +242,39 @@ private function resolveStatus(int $itemsCreated, array $failures): string /** * @return array{0:?BackupItem,1:?array{policy_id:int,reason:string,status:int|string|null}} */ - private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $policy, ?string $actorEmail = null): array - { - $snapshot = $this->snapshotService->fetch($tenant, $policy, $actorEmail); + private function snapshotPolicy( + Tenant $tenant, + BackupSet $backupSet, + Policy $policy, + ?string $actorEmail = null, + bool $includeAssignments = false, + bool $includeScopeTags = false + ): array { + // Use orchestrator to capture policy + assignments into PolicyVersion first + $captureResult = $this->captureOrchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: $includeAssignments, + includeScopeTags: $includeScopeTags, + createdBy: $actorEmail, + metadata: [ + 'source' => 'backup', + 'backup_set_id' => $backupSet->id, + ] + ); - if (isset($snapshot['failure'])) { - return [null, $snapshot['failure']]; + // Check for capture failure + if (isset($captureResult['failure'])) { + return [null, $captureResult['failure']]; } - $payload = $snapshot['payload']; - $metadata = $snapshot['metadata'] ?? []; - $metadataWarnings = $snapshot['warnings'] ?? []; + $version = $captureResult['version']; + $captured = $captureResult['captured']; + $payload = $captured['payload']; + $metadata = $captured['metadata'] ?? []; + $metadataWarnings = $captured['warnings'] ?? []; + // Validate snapshot $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); @@ -209,28 +288,22 @@ private function snapshotPolicy(Tenant $tenant, BackupSet $backupSet, Policy $po $metadata['warnings'] = array_values(array_unique($metadataWarnings)); } + // Create BackupItem as a copy/reference of the PolicyVersion $backupItem = BackupItem::create([ 'tenant_id' => $tenant->id, 'backup_set_id' => $backupSet->id, 'policy_id' => $policy->id, + 'policy_version_id' => $version->id, // Link to version '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, ]); - $this->versionService->captureVersion( - policy: $policy, - payload: $payload, - createdBy: $actorEmail, - metadata: [ - 'source' => 'backup', - 'backup_set_id' => $backupSet->id, - 'backup_item_id' => $backupItem->id, - ] - ); - return [$backupItem, null]; } diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php new file mode 100644 index 0000000..0ac4573 --- /dev/null +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -0,0 +1,369 @@ + PolicyVersion, 'captured' => array] + */ + public function capture( + Policy $policy, + Tenant $tenant, + bool $includeAssignments = false, + bool $includeScopeTags = false, + ?string $createdBy = null, + array $metadata = [] + ): array { + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + // 1. Fetch policy snapshot + $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); + + if (isset($snapshot['failure'])) { + throw new \RuntimeException($snapshot['failure']['reason'] ?? 'Unable to fetch policy snapshot'); + } + + $payload = $snapshot['payload']; + $assignments = null; + $scopeTags = null; + $captureMetadata = []; + + // 2. Fetch assignments if requested + if ($includeAssignments) { + try { + $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + + if (! empty($rawAssignments)) { + $resolvedGroups = []; + + // Resolve groups for orphaned detection + $groupIds = collect($rawAssignments) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + + if (! empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + $captureMetadata['has_orphaned_assignments'] = collect($resolvedGroups) + ->contains(fn (array $group) => $group['orphaned'] ?? false); + } + + $filterIds = collect($rawAssignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); + $captureMetadata['assignments_count'] = count($rawAssignments); + } + } catch (\Throwable $e) { + $captureMetadata['assignments_fetch_failed'] = true; + $captureMetadata['assignments_fetch_error'] = $e->getMessage(); + + Log::warning('Failed to fetch assignments during capture', [ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'error' => $e->getMessage(), + ]); + } + } + + // 3. Fetch scope tags if requested + if ($includeScopeTags) { + $scopeTagIds = $payload['roleScopeTagIds'] ?? ['0']; + $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); + } + + // 4. Check if PolicyVersion with same snapshot already exists + $snapshotHash = hash('sha256', json_encode($payload)); + + // Find existing version by comparing snapshot content (database-agnostic) + $existingVersion = PolicyVersion::where('policy_id', $policy->id) + ->get() + ->first(function ($version) use ($snapshotHash) { + return hash('sha256', json_encode($version->snapshot)) === $snapshotHash; + }); + + if ($existingVersion) { + $updates = []; + + if ($includeAssignments && $existingVersion->assignments === null) { + $updates['assignments'] = $assignments; + $updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null; + } + + if ($includeScopeTags && $existingVersion->scope_tags === null) { + $updates['scope_tags'] = $scopeTags; + $updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null; + } + + if (! empty($updates)) { + $existingVersion->update($updates); + + Log::info('Backfilled existing PolicyVersion with capture data', [ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_id' => $existingVersion->id, + 'version_number' => $existingVersion->version_number, + 'assignments_backfilled' => array_key_exists('assignments', $updates), + 'scope_tags_backfilled' => array_key_exists('scope_tags', $updates), + ]); + + return [ + 'version' => $existingVersion->fresh(), + 'captured' => [ + 'payload' => $payload, + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'metadata' => $captureMetadata, + ], + ]; + } + + Log::info('Reusing existing PolicyVersion', [ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_id' => $existingVersion->id, + 'version_number' => $existingVersion->version_number, + ]); + + return [ + 'version' => $existingVersion, + 'captured' => [ + 'payload' => $payload, + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'metadata' => $captureMetadata, + ], + ]; + } + + // 5. Create new PolicyVersion with all captured data + $metadata = array_merge( + ['source' => 'orchestrated_capture'], + $metadata, + $captureMetadata + ); + + $version = $this->versionService->captureVersion( + policy: $policy, + payload: $payload, + createdBy: $createdBy, + metadata: $metadata, + assignments: $assignments, + scopeTags: $scopeTags, + ); + + Log::info('Policy captured via orchestrator', [ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_id' => $version->id, + 'version_number' => $version->version_number, + 'has_assignments' => ! is_null($assignments), + 'has_scope_tags' => ! is_null($scopeTags), + ]); + + return [ + 'version' => $version, + 'captured' => [ + 'payload' => $payload, + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'metadata' => $captureMetadata, + ], + ]; + } + + /** + * Ensure existing PolicyVersion has assignments if missing. + */ + public function ensureVersionHasAssignments( + PolicyVersion $version, + Tenant $tenant, + Policy $policy, + bool $includeAssignments = false, + bool $includeScopeTags = false + ): PolicyVersion { + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + if ($version->assignments !== null && $version->scope_tags !== null) { + Log::debug('Version already has assignments, skipping', [ + 'version_id' => $version->id, + ]); + + return $version; + } + + // Only fetch if requested + if (! $includeAssignments && ! $includeScopeTags) { + return $version; + } + + $assignments = null; + $scopeTags = null; + $metadata = $version->metadata ?? []; + + if ($includeAssignments && $version->assignments === null) { + try { + $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + + if (! empty($rawAssignments)) { + $resolvedGroups = []; + + // Resolve groups + $groupIds = collect($rawAssignments) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + + if (! empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + $metadata['has_orphaned_assignments'] = collect($resolvedGroups) + ->contains(fn (array $group) => $group['orphaned'] ?? false); + } + + $filterIds = collect($rawAssignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); + $metadata['assignments_count'] = count($rawAssignments); + } + } catch (\Throwable $e) { + $metadata['assignments_fetch_failed'] = true; + $metadata['assignments_fetch_error'] = $e->getMessage(); + + Log::warning('Failed to backfill assignments for version', [ + 'version_id' => $version->id, + 'error' => $e->getMessage(), + ]); + } + } + + // Fetch scope tags + if ($includeScopeTags && $version->scope_tags === null) { + $scopeTagIds = $version->snapshot['roleScopeTagIds'] ?? ['0']; + $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); + } + + $updates = []; + + if ($includeAssignments && $version->assignments === null) { + $updates['assignments'] = $assignments; + $updates['assignments_hash'] = $assignments ? hash('sha256', json_encode($assignments)) : null; + } + + if ($includeScopeTags && $version->scope_tags === null) { + $updates['scope_tags'] = $scopeTags; + $updates['scope_tags_hash'] = $scopeTags ? hash('sha256', json_encode($scopeTags)) : null; + } + + if (! empty($updates)) { + $updates['metadata'] = $metadata; + $version->update($updates); + } + + Log::info('Version backfilled with capture data', [ + 'version_id' => $version->id, + 'has_assignments' => ! is_null($assignments), + 'has_scope_tags' => ! is_null($scopeTags), + ]); + + return $version->refresh(); + } + + /** + * @param array> $assignments + * @param array $groups + * @param array $filterNames + * @return array> + */ + private function enrichAssignments(array $assignments, array $groups, array $filterNames): array + { + return array_map(function (array $assignment) use ($groups, $filterNames): array { + $target = $assignment['target'] ?? []; + $groupId = $target['groupId'] ?? null; + + if ($groupId && isset($groups[$groupId])) { + $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; + $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; + } + + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + if ($filterId && isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } + + $assignment['target'] = $target; + + return $assignment; + }, $assignments); + } + + /** + * @param array $scopeTagIds + * @return array{ids:array,names:array} + */ + private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array + { + $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant); + + $names = []; + foreach ($scopeTagIds as $id) { + $scopeTag = collect($scopeTags)->firstWhere('id', $id); + $names[] = $scopeTag['displayName'] ?? ($id === '0' ? 'Default' : "Unknown (ID: {$id})"); + } + + return [ + 'ids' => $scopeTagIds, + 'names' => $names, + ]; + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 726f7ce..71a8210 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -7,6 +7,7 @@ use App\Models\Policy; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\AssignmentRestoreService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphErrorMapper; @@ -25,6 +26,7 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, + private readonly AssignmentRestoreService $assignmentRestoreService, ) {} /** @@ -73,6 +75,7 @@ public function execute( bool $dryRun = true, ?string $actorEmail = null, ?string $actorName = null, + array $groupMapping = [], ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); @@ -90,8 +93,28 @@ public function execute( 'preview' => $preview, 'started_at' => CarbonImmutable::now(), 'metadata' => [], + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); + if ($groupMapping !== []) { + $this->auditLogger->log( + tenant: $tenant, + action: 'restore.group_mapping.applied', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'mapped_groups' => count($groupMapping), + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success' + ); + } + $results = []; $hardFailures = 0; @@ -265,6 +288,31 @@ public function execute( continue; } + $assignmentOutcomes = null; + $assignmentSummary = null; + + if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { + $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; + + $assignmentOutcomes = $this->assignmentRestoreService->restore( + tenant: $tenant, + policyType: $item->policy_type, + policyId: $assignmentPolicyId, + assignments: $item->assignments, + groupMapping: $groupMapping, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + ); + + $assignmentSummary = $assignmentOutcomes['summary'] ?? null; + + if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') { + $itemStatus = 'partial'; + $resultReason = 'Assignments restored with failures'; + } + } + $result = $context + ['status' => $itemStatus]; if ($settingsApply !== null) { @@ -285,6 +333,14 @@ public function execute( $result['reason'] = 'Some settings require attention'; } + if ($assignmentOutcomes !== null) { + $result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? []; + } + + if ($assignmentSummary !== null) { + $result['assignment_summary'] = $assignmentSummary; + } + $results[] = $result; $appliedPolicyId = $item->policy_identifier; diff --git a/app/Services/Intune/TenantPermissionService.php b/app/Services/Intune/TenantPermissionService.php index 32f3a7f..65eb18b 100644 --- a/app/Services/Intune/TenantPermissionService.php +++ b/app/Services/Intune/TenantPermissionService.php @@ -105,7 +105,7 @@ public function compare(Tenant $tenant, ?array $grantedStatuses = null, bool $pe $overall = match (true) { $hasErrors => 'error', $hasMissing => 'missing', - default => 'ok', + default => 'granted', }; return [ @@ -148,7 +148,7 @@ public function configuredGrantedStatuses(): array foreach ($configured as $key) { $normalized[$key] = [ - 'status' => 'ok', + 'status' => 'granted', 'details' => ['source' => 'configured'], ]; } @@ -204,7 +204,7 @@ private function fetchLivePermissions(Tenant $tenant): array foreach ($grantedPermissions as $permission) { $normalized[$permission] = [ - 'status' => 'ok', + 'status' => 'granted', 'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()], ]; } diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 83ab3fa..3d4f4da 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -5,6 +5,10 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Services\Graph\AssignmentFetcher; +use App\Services\Graph\AssignmentFilterResolver; +use App\Services\Graph\GroupResolver; +use App\Services\Graph\ScopeTagResolver; use Carbon\CarbonImmutable; class VersionService @@ -12,6 +16,10 @@ class VersionService public function __construct( private readonly AuditLogger $auditLogger, private readonly PolicySnapshotService $snapshotService, + private readonly AssignmentFetcher $assignmentFetcher, + private readonly GroupResolver $groupResolver, + private readonly AssignmentFilterResolver $assignmentFilterResolver, + private readonly ScopeTagResolver $scopeTagResolver, ) {} public function captureVersion( @@ -19,6 +27,8 @@ public function captureVersion( array $payload, ?string $createdBy = null, array $metadata = [], + ?array $assignments = null, + ?array $scopeTags = null, ): PolicyVersion { $versionNumber = $this->nextVersionNumber($policy); @@ -32,6 +42,10 @@ public function captureVersion( 'captured_at' => CarbonImmutable::now(), 'snapshot' => $payload, 'metadata' => $metadata, + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, + 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, ]); $this->auditLogger->log( @@ -56,7 +70,12 @@ public function captureFromGraph( Policy $policy, ?string $createdBy = null, array $metadata = [], + bool $includeAssignments = true, + bool $includeScopeTags = true, ): PolicyVersion { + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + $snapshot = $this->snapshotService->fetch($tenant, $policy, $createdBy); if (isset($snapshot['failure'])) { @@ -65,16 +84,123 @@ public function captureFromGraph( throw new \RuntimeException($reason); } - $metadata = array_merge(['source' => 'version_capture'], $metadata); + $payload = $snapshot['payload']; + $assignments = null; + $scopeTags = null; + $assignmentMetadata = []; + + if ($includeAssignments) { + try { + $rawAssignments = $this->assignmentFetcher->fetch($tenantIdentifier, $policy->external_id, $graphOptions); + + if (! empty($rawAssignments)) { + $resolvedGroups = []; + + // Resolve groups + $groupIds = collect($rawAssignments) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + + if (! empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + } + + $assignmentMetadata['has_orphaned_assignments'] = collect($resolvedGroups) + ->contains(fn (array $group) => $group['orphaned'] ?? false); + $assignmentMetadata['assignments_count'] = count($rawAssignments); + + $filterIds = collect($rawAssignments) + ->pluck('target.deviceAndAppManagementAssignmentFilterId') + ->filter() + ->unique() + ->values() + ->all(); + + $filters = $this->assignmentFilterResolver->resolve($filterIds, $tenant); + $filterNames = collect($filters) + ->pluck('displayName', 'id') + ->all(); + + $assignments = $this->enrichAssignments($rawAssignments, $resolvedGroups, $filterNames); + } + } catch (\Throwable $e) { + $assignmentMetadata['assignments_fetch_failed'] = true; + $assignmentMetadata['assignments_fetch_error'] = $e->getMessage(); + } + } + + if ($includeScopeTags) { + $scopeTagIds = $payload['roleScopeTagIds'] ?? ['0']; + $scopeTags = $this->resolveScopeTags($tenant, $scopeTagIds); + } + + $metadata = array_merge( + ['source' => 'version_capture'], + $metadata, + $assignmentMetadata + ); return $this->captureVersion( policy: $policy, - payload: $snapshot['payload'], + payload: $payload, createdBy: $createdBy, metadata: $metadata, + assignments: $assignments, + scopeTags: $scopeTags, ); } + /** + * @param array> $assignments + * @param array $groups + * @param array $filterNames + * @return array> + */ + private function enrichAssignments(array $assignments, array $groups, array $filterNames): array + { + return array_map(function (array $assignment) use ($groups, $filterNames): array { + $target = $assignment['target'] ?? []; + $groupId = $target['groupId'] ?? null; + + if ($groupId && isset($groups[$groupId])) { + $target['group_display_name'] = $groups[$groupId]['displayName'] ?? null; + $target['group_orphaned'] = $groups[$groupId]['orphaned'] ?? false; + } + + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + if ($filterId && isset($filterNames[$filterId])) { + $target['assignment_filter_name'] = $filterNames[$filterId]; + } + + $assignment['target'] = $target; + + return $assignment; + }, $assignments); + } + + /** + * @param array $scopeTagIds + * @return array{ids:array,names:array} + */ + private function resolveScopeTags(Tenant $tenant, array $scopeTagIds): array + { + $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds, $tenant); + + $names = []; + foreach ($scopeTagIds as $id) { + $scopeTag = collect($scopeTags)->firstWhere('id', $id); + $names[] = $scopeTag['displayName'] ?? ($id === '0' ? 'Default' : "Unknown (ID: {$id})"); + } + + return [ + 'ids' => $scopeTagIds, + 'names' => $names, + ]; + } + private function nextVersionNumber(Policy $policy): int { $current = PolicyVersion::query() diff --git a/config/graph_contracts.php b/config/graph_contracts.php index 50beb46..e77eff5 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -70,6 +70,19 @@ 'fallback_body_shape' => 'wrapped', ], 'update_strategy' => 'settings_catalog_policy_with_settings', + + // 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', ], 'deviceCompliancePolicy' => [ 'resource' => 'deviceManagement/deviceCompliancePolicies', diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 56ba8e1..935c368 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -56,6 +56,18 @@ 'description' => 'Read directory data needed for tenant health checks.', 'features' => ['tenant-health'], ], + [ + 'key' => 'DeviceManagementRBAC.Read.All', + 'type' => 'application', + 'description' => 'Read Intune RBAC settings including scope tags for backup metadata enrichment.', + 'features' => ['scope-tags', 'backup-metadata', 'assignments'], + ], + [ + 'key' => 'Group.Read.All', + 'type' => 'application', + 'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.', + 'features' => ['assignments', 'group-mapping', 'backup-metadata'], + ], [ 'key' => 'DeviceManagementScripts.ReadWrite.All', 'type' => 'application', @@ -66,8 +78,14 @@ // Stub list of permissions already granted to the service principal (used for display in Tenant verification UI). // Diese Liste sollte mit den tatsächlich in Entra ID granted permissions übereinstimmen. // HINWEIS: In Produktion sollte dies dynamisch von Graph API abgerufen werden (geplant für v1.1+). + // + // ⚠️ WICHTIG: Nach dem Hinzufügen neuer Berechtigungen in Azure AD: + // 1. Berechtigungen in Azure AD hinzufügen und Admin Consent geben + // 2. Diese Liste unten aktualisieren (von "Required permissions" nach "Tatsächlich granted" verschieben) + // 3. Cache leeren: php artisan cache:clear + // 4. Optional: Live-Check auf Tenant-Detailseite ausführen 'granted_stub' => [ - // Tatsächlich granted (aus Entra ID Screenshot): + // Tatsächlich granted (aus Entra ID): 'Device.Read.All', 'DeviceManagementConfiguration.Read.All', 'DeviceManagementConfiguration.ReadWrite.All', @@ -76,6 +94,10 @@ 'Directory.Read.All', 'User.Read', 'DeviceManagementScripts.ReadWrite.All', + + // Feature 004 - Assignments & Scope Tags (granted seit 2025-12-22): + 'DeviceManagementRBAC.Read.All', // Scope Tag Namen auflösen + 'Group.Read.All', // Group Namen für Assignments auflösen // Required permissions (müssen in Entra ID granted werden): // Wenn diese fehlen, erscheinen sie als "missing" in der UI diff --git a/database/factories/BackupItemFactory.php b/database/factories/BackupItemFactory.php new file mode 100644 index 0000000..5dbae1b --- /dev/null +++ b/database/factories/BackupItemFactory.php @@ -0,0 +1,35 @@ + + */ +class BackupItemFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'backup_set_id' => BackupSet::factory(), + 'policy_id' => Policy::factory(), + 'policy_identifier' => fake()->uuid(), + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']), + 'captured_at' => now(), + 'payload' => ['id' => fake()->uuid(), 'name' => fake()->words(3, true)], + 'metadata' => ['policy_name' => fake()->words(3, true)], + 'assignments' => null, + ]; + } +} diff --git a/database/factories/BackupSetFactory.php b/database/factories/BackupSetFactory.php new file mode 100644 index 0000000..ed09eb9 --- /dev/null +++ b/database/factories/BackupSetFactory.php @@ -0,0 +1,30 @@ + + */ +class BackupSetFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'name' => fake()->words(3, true), + 'created_by' => fake()->email(), + 'status' => 'completed', + 'item_count' => fake()->numberBetween(0, 100), + 'completed_at' => now(), + 'metadata' => [], + ]; + } +} diff --git a/database/factories/PolicyFactory.php b/database/factories/PolicyFactory.php new file mode 100644 index 0000000..5b5bda8 --- /dev/null +++ b/database/factories/PolicyFactory.php @@ -0,0 +1,30 @@ + + */ +class PolicyFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'external_id' => fake()->uuid(), + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']), + 'display_name' => fake()->words(3, true), + 'last_synced_at' => now(), + 'metadata' => [], + ]; + } +} diff --git a/database/factories/PolicyVersionFactory.php b/database/factories/PolicyVersionFactory.php new file mode 100644 index 0000000..fe87d32 --- /dev/null +++ b/database/factories/PolicyVersionFactory.php @@ -0,0 +1,30 @@ + + */ +class PolicyVersionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => \App\Models\Tenant::factory(), + 'policy_id' => \App\Models\Policy::factory(), + 'version_number' => 1, + 'policy_type' => 'deviceManagementConfigurationPolicy', + 'platform' => 'windows10', + 'snapshot' => ['test' => 'data'], + 'metadata' => [], + 'captured_at' => now(), + ]; + } +} diff --git a/database/factories/RestoreRunFactory.php b/database/factories/RestoreRunFactory.php new file mode 100644 index 0000000..e3b2afd --- /dev/null +++ b/database/factories/RestoreRunFactory.php @@ -0,0 +1,35 @@ + + */ +class RestoreRunFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'backup_set_id' => BackupSet::factory(), + 'status' => 'completed', + 'is_dry_run' => false, + 'requested_items' => [], + 'preview' => [], + 'results' => [], + 'metadata' => [], + 'group_mapping' => null, + 'started_at' => now()->subHour(), + 'completed_at' => now(), + ]; + } +} diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php new file mode 100644 index 0000000..3a3e4ea --- /dev/null +++ b/database/factories/TenantFactory.php @@ -0,0 +1,27 @@ + + */ +class TenantFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'tenant_id' => fake()->uuid(), + 'app_client_id' => fake()->uuid(), + 'app_client_secret' => null, // Skip encryption in tests + 'metadata' => [], + ]; + } +} diff --git a/database/migrations/2025_12_22_004948_add_assignments_to_backup_items.php b/database/migrations/2025_12_22_004948_add_assignments_to_backup_items.php new file mode 100644 index 0000000..77a3066 --- /dev/null +++ b/database/migrations/2025_12_22_004948_add_assignments_to_backup_items.php @@ -0,0 +1,28 @@ +json('assignments')->nullable()->after('metadata'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_items', function (Blueprint $table) { + $table->dropColumn('assignments'); + }); + } +}; diff --git a/database/migrations/2025_12_22_004957_add_group_mapping_to_restore_runs.php b/database/migrations/2025_12_22_004957_add_group_mapping_to_restore_runs.php new file mode 100644 index 0000000..6f3d5e5 --- /dev/null +++ b/database/migrations/2025_12_22_004957_add_group_mapping_to_restore_runs.php @@ -0,0 +1,28 @@ +json('group_mapping')->nullable()->after('results'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('restore_runs', function (Blueprint $table) { + $table->dropColumn('group_mapping'); + }); + } +}; diff --git a/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php b/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php new file mode 100644 index 0000000..efc7a04 --- /dev/null +++ b/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php @@ -0,0 +1,36 @@ +json('assignments')->nullable()->after('metadata'); + $table->json('scope_tags')->nullable()->after('assignments'); + $table->string('assignments_hash', 64)->nullable()->after('scope_tags'); + $table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash'); + + $table->index('assignments_hash'); + $table->index('scope_tags_hash'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('policy_versions', function (Blueprint $table) { + $table->dropIndex(['assignments_hash']); + $table->dropIndex(['scope_tags_hash']); + $table->dropColumn(['assignments', 'scope_tags', 'assignments_hash', 'scope_tags_hash']); + }); + } +}; diff --git a/database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php b/database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php new file mode 100644 index 0000000..c92f20e --- /dev/null +++ b/database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php @@ -0,0 +1,31 @@ +foreignId('policy_version_id')->nullable()->after('policy_id')->constrained('policy_versions')->nullOnDelete(); + $table->index('policy_version_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_items', function (Blueprint $table) { + $table->dropForeign(['policy_version_id']); + $table->dropIndex(['policy_version_id']); + $table->dropColumn('policy_version_id'); + }); + } +}; diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md new file mode 100644 index 0000000..6eabcb8 --- /dev/null +++ b/docs/PERMISSIONS.md @@ -0,0 +1,154 @@ +# Microsoft Graph API Permissions + +This document lists all required Microsoft Graph API permissions for TenantPilot to function correctly. + +## Required Permissions + +The Azure AD / Entra ID **App Registration** used by TenantPilot requires the following **Application Permissions** (not Delegated): + +### Core Policy Management (Required) +- `DeviceManagementConfiguration.Read.All` - Read Intune device configuration policies +- `DeviceManagementConfiguration.ReadWrite.All` - Write/restore Intune policies +- `DeviceManagementApps.Read.All` - Read app configuration policies +- `DeviceManagementApps.ReadWrite.All` - Write app policies + +### Scope Tags (Feature 004 - Required for Phase 3) +- **`DeviceManagementRBAC.Read.All`** - Read scope tags and RBAC settings + - **Purpose**: Resolve scope tag IDs to display names (e.g., "0" → "Default") + - **Missing**: Backup items will show "Unknown (ID: 0)" instead of scope tag names + - **Impact**: Metadata display only - backups still work without this permission + +### Group Resolution (Feature 004 - Required for Phase 2) +- `Group.Read.All` - Resolve group IDs to names for assignments +- `Directory.Read.All` - Batch resolve directory objects (groups, users, devices) + +## How to Add Permissions + +### Azure Portal (Entra ID) + +1. Go to **Azure Portal** → **Entra ID** (Azure Active Directory) +2. Navigate to **App registrations** → Select your TenantPilot app +3. Click **API permissions** in the left menu +4. Click **+ Add a permission** +5. Select **Microsoft Graph** → **Application permissions** +6. Search for and select the required permissions: + - `DeviceManagementRBAC.Read.All` + - (Add others as needed) +7. Click **Add permissions** +8. **IMPORTANT**: Click **Grant admin consent for [Your Organization]** + - ⚠️ Without admin consent, the permissions won't be active! + +### PowerShell (Alternative) + +```powershell +# Connect to Microsoft Graph +Connect-MgGraph -Scopes "Application.ReadWrite.All" + +# Get your app registration +$appId = "YOUR-APP-CLIENT-ID" +$app = Get-MgApplication -Filter "appId eq '$appId'" + +# Add DeviceManagementRBAC.Read.All permission +$graphServicePrincipal = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" +$rbacPermission = $graphServicePrincipal.AppRoles | Where-Object {$_.Value -eq "DeviceManagementRBAC.Read.All"} + +$requiredResourceAccess = @{ + ResourceAppId = "00000003-0000-0000-c000-000000000000" + ResourceAccess = @( + @{ + Id = $rbacPermission.Id + Type = "Role" + } + ) +} + +Update-MgApplication -ApplicationId $app.Id -RequiredResourceAccess $requiredResourceAccess + +# Grant admin consent +# (Must be done manually or via Graph API with RoleManagement.ReadWrite.Directory scope) +``` + +## Verification + +After adding permissions and granting admin consent: + +1. Go to **App registrations** → Your app → **API permissions** +2. Verify status shows **Granted for [Your Organization]** with a green checkmark ✅ +3. Clear cache in TenantPilot: + ```bash + php artisan cache:clear + ``` +4. Test scope tag resolution: + ```bash + php artisan tinker + >>> use App\Services\Graph\ScopeTagResolver; + >>> use App\Models\Tenant; + >>> $tenant = Tenant::first(); + >>> $resolver = app(ScopeTagResolver::class); + >>> $tags = $resolver->resolve(['0'], $tenant); + >>> dd($tags); + ``` + Expected output: + ```php + [ + [ + "id" => "0", + "displayName" => "Default" + ] + ] + ``` + +## Troubleshooting + +### Error: "Application is not authorized to perform this operation" + +**Symptoms:** +- Backup items show "Unknown (ID: 0)" for scope tags +- Logs contain: `Application must have one of the following scopes: DeviceManagementRBAC.Read.All` + +**Solution:** +1. Add `DeviceManagementRBAC.Read.All` permission (see above) +2. **Grant admin consent** (critical step!) +3. Wait 5-10 minutes for Azure to propagate permissions +4. Clear cache: `php artisan cache:clear` +5. Test again + +### Error: "Insufficient privileges to complete the operation" + +**Cause:** The user account used to grant admin consent doesn't have sufficient permissions. + +**Solution:** +- Use an account with **Global Administrator** or **Privileged Role Administrator** role +- Or have the IT admin grant consent for the organization + +### Permissions showing but still getting 403 + +**Possible causes:** +1. Admin consent not granted (click the button!) +2. Permissions not yet propagated (wait 5-10 minutes) +3. Wrong tenant (check tenant ID in app config) +4. Cached token needs refresh (clear cache + restart) + +## Feature Impact Matrix + +| Feature | Required Permissions | Without Permission | Impact Level | +|---------|---------------------|-------------------|--------------| +| Basic Policy Backup | `DeviceManagementConfiguration.Read.All` | Cannot backup | 🔴 Critical | +| Policy Restore | `DeviceManagementConfiguration.ReadWrite.All` | Cannot restore | 🔴 Critical | +| Scope Tag Names (004) | `DeviceManagementRBAC.Read.All` | Shows "Unknown (ID: X)" | 🟡 Medium | +| Assignment Names (004) | `Group.Read.All` + `Directory.Read.All` | Shows group IDs only | 🟡 Medium | +| Group Mapping (004) | `Group.Read.All` | Manual ID mapping required | 🟡 Medium | + +## Security Notes + +- All permissions are **Application Permissions** (app-level, not user-level) +- Requires **admin consent** from Global Administrator +- Use **least privilege principle**: Only add permissions for features you use +- Consider creating separate app registrations for different environments (dev/staging/prod) +- Rotate client secrets regularly (recommended: every 6 months) + +## References + +- [Microsoft Graph API Permissions](https://learn.microsoft.com/en-us/graph/permissions-reference) +- [Intune Graph API Overview](https://learn.microsoft.com/en-us/graph/api/resources/intune-graph-overview) +- [App Registration Best Practices](https://learn.microsoft.com/en-us/azure/active-directory/develop/security-best-practices-for-app-registration) diff --git a/resources/views/filament/infolists/entries/rbac-summary.blade.php b/resources/views/filament/infolists/entries/rbac-summary.blade.php index 03a16e0..0c4cf37 100644 --- a/resources/views/filament/infolists/entries/rbac-summary.blade.php +++ b/resources/views/filament/infolists/entries/rbac-summary.blade.php @@ -48,11 +48,18 @@ @if (empty($canaries))
No canary results recorded.
@else -
    +
      @foreach ($canaries as $key => $status) -
    • - {{ $key }}: - {{ $status }} +
    • + {{ $key }}: + + {{ $status }} +
    • @endforeach
    diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 2f15b6f..8c429e3 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -42,12 +42,90 @@ - @if (! empty($item['reason'])) + @php + $itemReason = $item['reason'] ?? null; + $itemGraphMessage = $item['graph_error_message'] ?? null; + @endphp + + @if (! empty($itemReason) && ($itemGraphMessage === null || $itemGraphMessage !== $itemReason))
    - {{ $item['reason'] }} + {{ $itemReason }}
    @endif + @if (! empty($item['assignment_summary']) && is_array($item['assignment_summary'])) + @php + $summary = $item['assignment_summary']; + $assignmentOutcomes = $item['assignment_outcomes'] ?? []; + $assignmentIssues = collect($assignmentOutcomes) + ->filter(fn ($outcome) => in_array($outcome['status'] ?? null, ['failed', 'skipped'], true)) + ->values(); + @endphp + +
    + Assignments: {{ (int) ($summary['success'] ?? 0) }} success • + {{ (int) ($summary['failed'] ?? 0) }} failed • + {{ (int) ($summary['skipped'] ?? 0) }} skipped +
    + + @if ($assignmentIssues->isNotEmpty()) +
    + Assignment details +
    + @foreach ($assignmentIssues as $outcome) + @php + $outcomeStatus = $outcome['status'] ?? 'unknown'; + $outcomeColor = match ($outcomeStatus) { + 'failed' => 'text-red-700 bg-red-100 border-red-200', + 'skipped' => 'text-amber-900 bg-amber-100 border-amber-200', + default => 'text-gray-700 bg-gray-100 border-gray-200', + }; + $assignmentGroupId = $outcome['group_id'] + ?? ($outcome['assignment']['target']['groupId'] ?? null); + @endphp + +
    +
    +
    + Assignment {{ $assignmentGroupId ?? 'unknown group' }} +
    + + {{ $outcomeStatus }} + +
    + + @if (! empty($outcome['mapped_group_id'])) +
    + Mapped to: {{ $outcome['mapped_group_id'] }} +
    + @endif + + @php + $outcomeReason = $outcome['reason'] ?? null; + $outcomeGraphMessage = $outcome['graph_error_message'] ?? null; + @endphp + + @if (! empty($outcomeReason) && ($outcomeGraphMessage === null || $outcomeGraphMessage !== $outcomeReason)) +
    + {{ $outcomeReason }} +
    + @endif + + @if (! empty($outcome['graph_error_message']) || ! empty($outcome['graph_error_code'])) +
    +
    {{ $outcome['graph_error_message'] ?? 'Unknown error' }}
    + @if (! empty($outcome['graph_error_code'])) +
    Code: {{ $outcome['graph_error_code'] }}
    + @endif +
    + @endif +
    + @endforeach +
    +
    + @endif + @endif + @if (! empty($item['created_policy_id'])) @php $createdMode = $item['created_policy_mode'] ?? null; diff --git a/resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php b/resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php new file mode 100644 index 0000000..8205dfd --- /dev/null +++ b/resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php @@ -0,0 +1 @@ +@livewire('policy-version-assignments-widget', ['version' => $record]) diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php new file mode 100644 index 0000000..fb6238b --- /dev/null +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -0,0 +1,138 @@ +
    + @if($version->assignments && count($version->assignments) > 0) +
    +
    +
    +
    +

    + Assignments +

    +

    + Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }} +

    +
    +
    +
    + +
    + +
    +

    Summary

    +

    + {{ count($version->assignments) }} assignment(s) + @php + $hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false; + @endphp + @if($hasOrphaned) + (includes orphaned groups) + @endif +

    +
    + + + @php + $scopeTags = $version->scope_tags['names'] ?? []; + @endphp + @if(!empty($scopeTags)) +
    +

    Scope Tags

    +
    + @foreach($scopeTags as $tag) + + {{ $tag }} + + @endforeach +
    +
    + @endif + + +
    +

    Assignment Details

    +
    + @foreach($version->assignments as $assignment) + @php + $target = $assignment['target'] ?? []; + $type = $target['@odata.type'] ?? ''; + $typeKey = strtolower((string) $type); + $intent = $assignment['intent'] ?? 'apply'; + + $typeName = match (true) { + str_contains($typeKey, 'exclusiongroupassignmenttarget') => 'Exclude group', + str_contains($typeKey, 'groupassignmenttarget') => 'Include group', + str_contains($typeKey, 'alllicensedusersassignmenttarget') => 'All Users', + str_contains($typeKey, 'alldevicesassignmenttarget') => 'All Devices', + default => 'Unknown', + }; + + $groupId = $target['groupId'] ?? null; + $groupName = $target['group_display_name'] ?? null; + $groupOrphaned = $target['group_orphaned'] ?? ($version->metadata['has_orphaned_assignments'] ?? false); + $filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null; + $filterTypeRaw = strtolower((string) ($target['deviceAndAppManagementAssignmentFilterType'] ?? 'none')); + $filterType = $filterTypeRaw !== '' ? $filterTypeRaw : 'none'; + $filterName = $target['assignment_filter_name'] ?? null; + $filterLabel = $filterName ?? $filterId; + @endphp + +
    + + {{ $typeName }} + + @if($groupId) + : + @if($groupOrphaned) + + ⚠️ Unknown group (ID: {{ $groupId }}) + + @elseif($groupName) + + {{ $groupName }} + + + ({{ $groupId }}) + + @else + + Group ID: {{ $groupId }} + + @endif + @endif + + @if($filterLabel) + + Filter{{ $filterType !== 'none' ? " ({$filterType})" : '' }}: {{ $filterLabel }} + + @endif + + ({{ $intent }}) +
    + @endforeach +
    +
    +
    +
    + @else +
    +
    +

    + Assignments +

    +

    + Assignments were not captured for this version. +

    + @php + $hasBackupItem = $version->policy->backupItems() + ->whereNotNull('assignments') + ->where('created_at', '<=', $version->captured_at) + ->exists(); + @endphp + @if($hasBackupItem) +

    + 💡 Assignment data may be available in related backup items. +

    + @endif +
    +
    + @endif +
    diff --git a/specs/001-rbac-onboarding/plan.md b/specs/001-rbac-onboarding/plan.md index 94f9ab0..dadc63f 100644 --- a/specs/001-rbac-onboarding/plan.md +++ b/specs/001-rbac-onboarding/plan.md @@ -5,7 +5,7 @@ # Implementation Plan: TenantPilot v1 - RBAC Onboarding ## Summary -TenantPilot v1 core flows are already implemented per `specs/001-rbac-onboarding/tasks.md`. This plan focuses on finishing the remaining open items for this branch: US4 restore rerun (T156), optional RBAC check/report CLI (T167), and Settings Catalog improvements (T179, T185, T186). The RBAC onboarding wizard (US7) is tenant scoped, uses delegated login, and applies idempotent RBAC setup with audit logging. All Graph calls stay behind the Graph abstraction and contract registry. +TenantPilot v1 core flows are already implemented per `specs/001-rbac-onboarding/tasks.md`. This plan focuses on finishing the remaining open items for this branch: US4 restore rerun (T156), optional RBAC check/report CLI (T167), and Settings Catalog payload preservation (T186). Settings Catalog hydration and UI improvements (T179, T185) are now handled by Feature 003. The RBAC onboarding wizard (US7) is tenant scoped, uses delegated login, and applies idempotent RBAC setup with audit logging. All Graph calls stay behind the Graph abstraction and contract registry. ## Technical Context @@ -69,8 +69,7 @@ ### Phase B - Restore rerun UX - Implement T156: rerun action clones restore run (backup_set_id, items, dry_run) and enforces same safety gates. ### Phase C - Settings Catalog restore correctness and readability -- Implement T179: central hydration of settingsCatalogPolicy snapshots (versions, backups, previews). -- Implement T185: improve labels/value previews in settings table. +- ✅ T179 & T185: Moved to Feature 003 (settings-catalog-readable) - Implement T186: ensure settings_apply payload preserves @odata.type and correct body shape. ### Testing and Quality Gates diff --git a/specs/004-assignments-scope-tags/data-model.md b/specs/004-assignments-scope-tags/data-model.md new file mode 100644 index 0000000..036e497 --- /dev/null +++ b/specs/004-assignments-scope-tags/data-model.md @@ -0,0 +1,653 @@ +# Feature 004: Assignments & Scope Tags - Data Model + +## Overview +This document defines the database schema changes, model relationships, and data structures for the Assignments & Scope Tags feature. + +--- + +## Database Schema Changes + +### Migration 1: Add `assignments` column to `backup_items` + +**File**: `database/migrations/xxxx_add_assignments_to_backup_items.php` + +```php +json('assignments')->nullable()->after('metadata'); + }); + } + + public function down(): void + { + Schema::table('backup_items', function (Blueprint $table) { + $table->dropColumn('assignments'); + }); + } +}; +``` + +**JSONB Structure** (`backup_items.assignments`): +```json +[ + { + "id": "abc-123-def", + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "group-abc-123", + "deviceAndAppManagementAssignmentFilterId": null, + "deviceAndAppManagementAssignmentFilterType": "none" + }, + "intent": "apply", + "settings": null, + "source": "direct" + }, + { + "id": "def-456-ghi", + "target": { + "@odata.type": "#microsoft.graph.exclusionGroupAssignmentTarget", + "groupId": "group-def-456" + }, + "intent": "exclude" + } +] +``` + +**Index** (optional, for analytics queries): +```php +// Add GIN index for JSONB queries +DB::statement('CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments)'); +``` + +--- + +### Migration 2: Extend `backup_items.metadata` JSONB + +**Purpose**: Store assignment summary in metadata for quick access + +**Updated Schema** (`backup_items.metadata`): +```json +{ + // Existing fields + "policy_name": "Windows Security Baseline", + "policy_type": "settingsCatalogPolicy", + "tenant_name": "Contoso Corp", + + // NEW: Assignment metadata + "assignment_count": 5, + "scope_tag_ids": ["0", "abc-123", "def-456"], + "scope_tag_names": ["Default", "HR-Admins", "Finance-Admins"], + "has_orphaned_assignments": false, + "assignments_fetch_failed": false +} +``` + +**No Migration Needed**: `metadata` column already exists as JSONB, just update application code to populate these fields. + +--- + +### Migration 3: Add `group_mapping` column to `restore_runs` + +**File**: `database/migrations/xxxx_add_group_mapping_to_restore_runs.php` + +```php +json('group_mapping')->nullable()->after('results'); + }); + } + + public function down(): void + { + Schema::table('restore_runs', function (Blueprint $table) { + $table->dropColumn('group_mapping'); + }); + } +}; +``` + +**JSONB Structure** (`restore_runs.group_mapping`): +```json +{ + "source-group-abc-123": "target-group-xyz-789", + "source-group-def-456": "target-group-uvw-012", + "source-group-ghi-789": "SKIP" +} +``` + +**Usage**: Maps source tenant group IDs to target tenant group IDs during restore. Special value `"SKIP"` means do not restore assignments targeting that group. + +--- + +## Model Changes + +### BackupItem Model + +**File**: `app/Models/BackupItem.php` + +```php + 'array', + 'metadata' => 'array', + 'assignments' => 'array', // NEW + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // Relationships + public function backupSet() + { + return $this->belongsTo(BackupSet::class); + } + + // NEW: Assignment helpers + public function getAssignmentCountAttribute(): int + { + return count($this->assignments ?? []); + } + + public function hasAssignments(): bool + { + return !empty($this->assignments); + } + + public function getGroupIdsAttribute(): array + { + return collect($this->assignments ?? []) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + } + + public function getScopeTagIdsAttribute(): array + { + return $this->metadata['scope_tag_ids'] ?? ['0']; + } + + public function getScopeTagNamesAttribute(): array + { + return $this->metadata['scope_tag_names'] ?? ['Default']; + } + + public function hasOrphanedAssignments(): bool + { + return $this->metadata['has_orphaned_assignments'] ?? false; + } + + public function assignmentsFetchFailed(): bool + { + return $this->metadata['assignments_fetch_failed'] ?? false; + } + + // NEW: Scope for filtering policies with assignments + public function scopeWithAssignments($query) + { + return $query->whereNotNull('assignments') + ->whereRaw('json_array_length(assignments) > 0'); + } +} +``` + +--- + +### RestoreRun Model + +**File**: `app/Models/RestoreRun.php` + +```php + 'array', + 'group_mapping' => 'array', // NEW + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + // Relationships + public function backupSet() + { + return $this->belongsTo(BackupSet::class); + } + + public function targetTenant() + { + return $this->belongsTo(Tenant::class, 'target_tenant_id'); + } + + // NEW: Group mapping helpers + public function hasGroupMapping(): bool + { + return !empty($this->group_mapping); + } + + public function getMappedGroupId(string $sourceGroupId): ?string + { + return $this->group_mapping[$sourceGroupId] ?? null; + } + + public function isGroupSkipped(string $sourceGroupId): bool + { + return $this->group_mapping[$sourceGroupId] === 'SKIP'; + } + + public function getUnmappedGroupIds(array $sourceGroupIds): array + { + return array_diff($sourceGroupIds, array_keys($this->group_mapping ?? [])); + } + + public function addGroupMapping(string $sourceGroupId, string $targetGroupId): void + { + $mapping = $this->group_mapping ?? []; + $mapping[$sourceGroupId] = $targetGroupId; + $this->group_mapping = $mapping; + } + + // NEW: Assignment restore outcomes + public function getAssignmentRestoreOutcomes(): array + { + return $this->results['assignment_outcomes'] ?? []; + } + + public function getSuccessfulAssignmentsCount(): int + { + return count(array_filter( + $this->getAssignmentRestoreOutcomes(), + fn($outcome) => $outcome['status'] === 'success' + )); + } + + public function getFailedAssignmentsCount(): int + { + return count(array_filter( + $this->getAssignmentRestoreOutcomes(), + fn($outcome) => $outcome['status'] === 'failed' + )); + } + + public function getSkippedAssignmentsCount(): int + { + return count(array_filter( + $this->getAssignmentRestoreOutcomes(), + fn($outcome) => $outcome['status'] === 'skipped' + )); + } +} +``` + +--- + +### Policy Model (Extensions) + +**File**: `app/Models/Policy.php` + +```php +id}", 300, function () { + return app(AssignmentFetcher::class)->fetch($this->tenant_id, $this->graph_id); + }); + } + + public function hasAssignments(): bool + { + return !empty($this->assignments); + } + + // NEW: Scope for policies that support assignments + public function scopeSupportsAssignments($query) + { + // Only Settings Catalog policies support assignments in Phase 1 + return $query->where('type', 'settingsCatalogPolicy'); + } +} +``` + +--- + +## Service Layer Data Structures + +### AssignmentFetcher Service + +**Output Structure**: +```php +[ + [ + 'id' => 'abc-123-def', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-abc-123', + 'deviceAndAppManagementAssignmentFilterId' => null, + 'deviceAndAppManagementAssignmentFilterType' => 'none', + ], + 'intent' => 'apply', + 'settings' => null, + 'source' => 'direct', + ], + // ... more assignments +] +``` + +--- + +### GroupResolver Service + +**Input**: Array of group IDs +**Output**: +```php +[ + 'group-abc-123' => [ + 'id' => 'group-abc-123', + 'displayName' => 'All Users', + 'orphaned' => false, + ], + 'group-def-456' => [ + 'id' => 'group-def-456', + 'displayName' => null, + 'orphaned' => true, // Group doesn't exist in tenant + ], +] +``` + +--- + +### ScopeTagResolver Service + +**Input**: Array of scope tag IDs +**Output**: +```php +[ + [ + 'id' => '0', + 'displayName' => 'Default', + ], + [ + 'id' => 'abc-123-def', + 'displayName' => 'HR-Admins', + ], +] +``` + +--- + +### AssignmentRestoreService + +**Input**: Policy ID, assignments array, group mapping +**Output**: +```php +[ + [ + 'status' => 'success', + 'assignment' => [...], + 'assignment_id' => 'new-abc-123', + ], + [ + 'status' => 'failed', + 'assignment' => [...], + 'error' => 'Group not found: xyz-789', + 'request_id' => 'abc-def-ghi', + ], + [ + 'status' => 'skipped', + 'assignment' => [...], + ], +] +``` + +--- + +## Audit Log Entries + +### New Action Types + +**File**: `config/audit_log_actions.php` (if exists, or add to model) + +```php +return [ + // Existing actions... + + // NEW: Assignment backup/restore actions + 'backup.assignments.included' => 'Backup created with assignments', + 'backup.assignments.fetch_failed' => 'Failed to fetch assignments during backup', + + 'restore.group_mapping.applied' => 'Group mapping applied during restore', + 'restore.assignment.created' => 'Assignment created during restore', + 'restore.assignment.failed' => 'Assignment failed to restore', + 'restore.assignment.skipped' => 'Assignment skipped (group mapping)', + + 'policy.assignments.viewed' => 'Policy assignments viewed', +]; +``` + +### Example Audit Log Entries + +```php +// Backup with assignments +AuditLog::create([ + 'user_id' => auth()->id(), + 'tenant_id' => $tenant->id, + 'action' => 'backup.assignments.included', + 'resource_type' => 'backup_set', + 'resource_id' => $backupSet->id, + 'metadata' => [ + 'policy_count' => 15, + 'assignment_count' => 47, + ], +]); + +// Group mapping applied +AuditLog::create([ + 'user_id' => auth()->id(), + 'tenant_id' => $targetTenant->id, + 'action' => 'restore.group_mapping.applied', + 'resource_type' => 'restore_run', + 'resource_id' => $restoreRun->id, + 'metadata' => [ + 'source_tenant_id' => $sourceTenant->id, + 'group_mapping' => $restoreRun->group_mapping, + 'mapped_count' => 5, + 'skipped_count' => 2, + ], +]); + +// Assignment created +AuditLog::create([ + 'user_id' => auth()->id(), + 'tenant_id' => $targetTenant->id, + 'action' => 'restore.assignment.created', + 'resource_type' => 'assignment', + 'resource_id' => $assignmentId, + 'metadata' => [ + 'policy_id' => $policyId, + 'target_group_id' => $targetGroupId, + 'intent' => 'apply', + ], +]); +``` + +--- + +## PostgreSQL Indexes + +### Recommended Indexes + +```sql +-- GIN index for JSONB assignment queries (optional, for analytics) +CREATE INDEX backup_items_assignments_gin ON backup_items USING gin (assignments); + +-- Index for filtering policies with assignments +CREATE INDEX backup_items_assignments_not_null + ON backup_items (id) + WHERE assignments IS NOT NULL; + +-- Index for restore runs with group mapping +CREATE INDEX restore_runs_group_mapping_not_null + ON restore_runs (id) + WHERE group_mapping IS NOT NULL; + +-- Composite index for tenant + resource type queries +CREATE INDEX backup_items_tenant_type_idx + ON backup_items (tenant_id, resource_type, created_at DESC); +``` + +--- + +## Data Size Estimates + +### Storage Impact + +| Entity | Existing Size | Assignment Data | Total Size | Growth | +|--------|--------------|----------------|-----------|--------| +| `backup_items` (1 policy) | ~10-50 KB | ~2-5 KB | ~12-55 KB | +20-40% | +| `backup_items` (100 policies) | ~1-5 MB | ~200-500 KB | ~1.2-5.5 MB | +20-40% | +| `restore_runs` (with mapping) | ~5-10 KB | ~1-2 KB | ~6-12 KB | +20% | + +**Rationale**: Assignments are relatively small JSON objects. Even policies with 20+ assignments stay under 10 KB for assignment data. + +--- + +## Migration Rollback Strategy + +### If Rollback Needed + +```php +// Rollback Migration 1 +php artisan migrate:rollback --step=1 +// Drops `backup_items.assignments` column + +// Rollback Migration 2 +php artisan migrate:rollback --step=1 +// Drops `restore_runs.group_mapping` column +``` + +**Data Loss**: Rolling back will lose all stored assignments and group mappings. Backups can be re-created with assignments after rolling forward again. + +**Safe Rollback**: Since assignments are optional (controlled by checkbox), existing backups without assignments remain functional. + +--- + +## Validation Rules + +### BackupItem Validation + +```php +// In BackupItem model or Form Request +public static function assignmentsValidationRules(): array +{ + return [ + 'assignments' => ['nullable', 'array'], + 'assignments.*.id' => ['required', 'string'], + 'assignments.*.target' => ['required', 'array'], + 'assignments.*.target.@odata.type' => ['required', 'string'], + 'assignments.*.target.groupId' => ['required_if:assignments.*.target.@odata.type,#microsoft.graph.groupAssignmentTarget', 'string'], + 'assignments.*.intent' => ['required', 'string', 'in:apply,exclude'], + ]; +} +``` + +### RestoreRun Group Mapping Validation + +```php +// In RestoreRun model or Form Request +public static function groupMappingValidationRules(): array +{ + return [ + 'group_mapping' => ['nullable', 'array'], + 'group_mapping.*' => ['string'], // Target group ID or "SKIP" + ]; +} +``` + +--- + +## Summary + +### Database Changes +- ✅ `backup_items.assignments` (JSONB, nullable) +- ✅ `backup_items.metadata` (extend with assignment summary) +- ✅ `restore_runs.group_mapping` (JSONB, nullable) + +### Model Enhancements +- ✅ `BackupItem`: Assignment accessors, scopes, helpers +- ✅ `RestoreRun`: Group mapping helpers, outcome methods +- ✅ `Policy`: Virtual assignments relationship (cached) + +### Indexes +- ✅ GIN index on `backup_items.assignments` (optional) +- ✅ Partial indexes for non-null checks + +### Data Structures +- ✅ Assignment JSON schema defined +- ✅ Group mapping JSON schema defined +- ✅ Audit log action types defined + +--- + +**Status**: Data Model Complete +**Next Document**: `quickstart.md` diff --git a/specs/004-assignments-scope-tags/plan.md b/specs/004-assignments-scope-tags/plan.md new file mode 100644 index 0000000..70416cd --- /dev/null +++ b/specs/004-assignments-scope-tags/plan.md @@ -0,0 +1,461 @@ +# Feature 004: Assignments & Scope Tags - Implementation Plan + +## Project Context + +### Technical Foundation +- **Laravel**: 12 (latest stable) +- **PHP**: 8.4.15 +- **Admin UI**: Filament v4 +- **Interactive Components**: Livewire v3 +- **Database**: PostgreSQL with JSONB +- **External API**: Microsoft Graph API (Intune endpoints) +- **Testing**: Pest v4 (unit, feature, browser tests) +- **Local Dev**: Laravel Sail (Docker) +- **Deployment**: Dokploy (VPS, staging + production) + +### Constitution Check +✅ Spec reviewed: `specs/004-assignments-scope-tags/spec.md` +✅ Constitution followed: `.specify/constitution.md` +✅ SDD workflow: Feature branch → spec + code → PR to dev +✅ Multi-agent coordination: Session branch pattern recommended + +### Project Structure +``` +app/ +├── Models/ +│ ├── BackupItem.php # Add assignments column +│ ├── RestoreRun.php # Add group_mapping column +│ └── Policy.php # Add assignments relationship methods +├── Services/ +│ ├── Graph/ +│ │ ├── AssignmentFetcher.php # NEW: Fetch assignments with fallback +│ │ ├── GroupResolver.php # NEW: Resolve group IDs to names +│ │ └── ScopeTagResolver.php # NEW: Resolve scope tag IDs with cache +│ ├── AssignmentBackupService.php # NEW: Backup assignments logic +│ └── AssignmentRestoreService.php # NEW: Restore assignments logic +├── Filament/ +│ ├── Resources/ +│ │ └── PolicyResource/ +│ │ └── Pages/ +│ │ └── ViewPolicy.php # Add Assignments tab +│ └── Forms/Components/ +│ └── GroupMappingWizard.php # NEW: Multi-step group mapping +└── Jobs/ + ├── FetchAssignmentsJob.php # NEW: Async assignment fetch + └── RestoreAssignmentsJob.php # NEW: Async assignment restore + +database/migrations/ +├── xxxx_add_assignments_to_backup_items.php +└── xxxx_add_group_mapping_to_restore_runs.php + +tests/ +├── Unit/ +│ ├── AssignmentFetcherTest.php +│ ├── GroupResolverTest.php +│ └── ScopeTagResolverTest.php +├── Feature/ +│ ├── BackupWithAssignmentsTest.php +│ ├── PolicyViewAssignmentsTabTest.php +│ ├── RestoreGroupMappingTest.php +│ └── RestoreAssignmentApplicationTest.php +└── Browser/ + └── GroupMappingWizardTest.php +``` + +--- + +## Implementation Phases + +### Phase 1: Setup & Database (Foundation) +**Duration**: 2-3 hours +**Goal**: Prepare data layer for storing assignments and group mappings + +**Tasks**: +1. Create migration for `backup_items.assignments` JSONB column +2. Create migration for `restore_runs.group_mapping` JSONB column +3. Update `BackupItem` model with `assignments` cast and accessor methods +4. Update `RestoreRun` model with `group_mapping` cast and helper methods +5. Add Graph contract config for assignments endpoints in `config/graph_contracts.php` +6. Create unit tests for model methods + +**Acceptance Criteria**: +- Migrations reversible and run cleanly on Sail +- Models have proper JSONB casts +- Unit tests pass for assignment/mapping accessors + +--- + +### Phase 2: Graph API Integration (Core Services) +**Duration**: 4-6 hours +**Goal**: Build reliable services to fetch/resolve assignments and scope tags + +**Tasks**: +1. Create `AssignmentFetcher` service with fallback strategy: + - Primary: GET `/assignments` + - Fallback: GET with `$expand=assignments` + - Error handling with fail-soft +2. Create `GroupResolver` service: + - POST `/directoryObjects/getByIds` batch resolution + - Handle orphaned IDs gracefully + - Cache resolved groups (5 min TTL) +3. Create `ScopeTagResolver` service: + - GET `/deviceManagement/roleScopeTags` + - Cache scope tags (1 hour TTL) + - Extract from policy payload's `roleScopeTagIds` +4. Write unit tests mocking Graph responses: + - Success scenarios + - Partial failures (some IDs not found) + - Complete failures (API down) + +**Acceptance Criteria**: +- Services handle all failure scenarios gracefully +- Tests achieve 90%+ coverage +- Fallback strategies proven with mocks +- Cache TTLs configurable via config + +--- + +### Phase 3: US1 - Backup with Assignments (MVP Core) +**Duration**: 4-5 hours +**Goal**: Allow admins to optionally include assignments in backups + +**Tasks**: +1. Add "Include Assignments & Scope Tags" checkbox to Backup creation form +2. Create `AssignmentBackupService`: + - Accept tenantId, policyId, includeAssignments flag + - Call `AssignmentFetcher` if flag enabled + - Resolve scope tag names via `ScopeTagResolver` + - Update `backup_items.metadata` with assignment count + - Store assignments in `backup_items.assignments` column +3. Dispatch async job `FetchAssignmentsJob` if checkbox enabled +4. Handle job failures: log warning, set `assignments_fetch_failed: true` +5. Create feature test: `BackupWithAssignmentsTest` + - Test backup with checkbox enabled + - Test backup with checkbox disabled + - Test assignment fetch failure handling +6. Add audit log entry: `backup.assignments.included` + +**Acceptance Criteria**: +- Checkbox appears on Settings Catalog backup forms only +- Assignments stored in JSONB with correct schema +- Metadata includes `assignment_count`, `scope_tag_ids`, `has_orphaned_assignments` +- Feature test passes with mocked Graph responses +- Audit log records decision + +--- + +### Phase 4: US2 - Policy View with Assignments Tab +**Duration**: 3-4 hours +**Goal**: Display assignments in read-only view for auditing + +**Tasks**: +1. Add "Assignments" tab to `PolicyResource/ViewPolicy.php` +2. Create Filament Table for assignments: + - Columns: Type, Name, Mode (Include/Exclude), ID + - Handle orphaned IDs: "Unknown Group (ID: {id})" with warning icon +3. Create Scope Tags section (list with IDs) +4. Handle empty state: "No assignments found" +5. Update `BackupItem` detail view to show assignment summary in metadata card +6. Create feature test: `PolicyViewAssignmentsTabTest` + - Test assignments table rendering + - Test orphaned ID display + - Test scope tags section + - Test empty state + +**Acceptance Criteria**: +- Tab visible only for Settings Catalog policies with assignments +- Orphaned IDs render with clear warning +- Scope tags display with names + IDs +- Feature test passes + +--- + +### Phase 5: US3 - Restore with Group Mapping (Core Restore) +**Duration**: 6-8 hours +**Goal**: Enable cross-tenant restores with group mapping wizard + +**Tasks**: +1. Create `GroupMappingWizard` Filament multi-step form component: + - Step 1: Restore Preview (existing) + - Step 2: Group Mapping (NEW) + - Step 3: Confirm (existing) +2. Implement Group Mapping step logic: + - Detect unresolved groups via POST `/directoryObjects/getByIds` + - Fetch target tenant groups (with caching, 5 min TTL) + - Render table: Source Group | Target Group Dropdown | Skip Checkbox + - Search/filter support for 500+ groups +3. Persist group mapping in `restore_runs.group_mapping` JSON +4. Create `AssignmentRestoreService`: + - Accept policyId, assignments array, group_mapping object + - Replace source group IDs with mapped target IDs + - Skip assignments marked "Skip" + - Execute DELETE-then-CREATE pattern: + * GET existing assignments + * DELETE each (204 No Content expected) + * POST each new/mapped assignment (201 Created expected) + - Handle per-assignment failures (fail-soft) + - Log outcomes per assignment +5. Dispatch async job `RestoreAssignmentsJob` +6. Create feature test: `RestoreGroupMappingTest` + - Test group mapping wizard flow + - Test group ID resolution + - Test mapping persistence + - Test skip functionality +7. Add audit log entries: + - `restore.group_mapping.applied` + - `restore.assignment.created` (per assignment) + - `restore.assignment.skipped` + +**Acceptance Criteria**: +- Group mapping step appears only when unresolved groups exist +- Dropdown searchable with 500+ groups +- Mapping persisted and visible in audit logs +- Restore applies assignments correctly with mapped IDs +- Per-assignment outcomes logged +- Feature test passes + +--- + +### Phase 6: US4 - Restore Preview with Assignment Diff +**Duration**: 2-3 hours +**Goal**: Show admins what assignments will change before restore + +**Tasks**: +1. Enhance Restore Preview step to show assignment diff: + - Added assignments (green) + - Removed assignments (red) + - Unchanged assignments (gray) +2. Add scope tag diff: "Scope Tags: 2 matched, 1 not found in target" +3. Create diff algorithm: + - Compare source assignments with target policy's current assignments + - Group by change type (added/removed/unchanged) +4. Update feature test: `RestoreAssignmentApplicationTest` to verify diff display + +**Acceptance Criteria**: +- Diff shows clear visual indicators (colors, icons) +- Scope tag warnings visible +- Diff accurate for all scenarios (same tenant, cross-tenant, empty target) + +--- + +### Phase 7: Scope Tags (Full Support) +**Duration**: 2-3 hours +**Goal**: Complete scope tag handling in backup/restore + +**Tasks**: +1. Update `AssignmentBackupService` to extract `roleScopeTagIds` from policy payload +2. Resolve scope tag names via `ScopeTagResolver` during backup +3. Update restore logic to preserve scope tag IDs if they exist in target +4. Log warnings for missing scope tags (don't block restore) +5. Update unit test: `ScopeTagResolverTest` +6. Update feature test: Add scope tag scenarios to `RestoreAssignmentApplicationTest` + +**Acceptance Criteria**: +- Scope tags captured during backup with names +- Restore preserves IDs when available in target +- Warnings logged for missing scope tags +- Tests pass with various scope tag scenarios + +--- + +### Phase 8: Polish & Performance +**Duration**: 3-4 hours +**Goal**: Optimize performance and improve UX + +**Tasks**: +1. Add loading indicators to Group Mapping dropdown (wire:loading) +2. Implement group search debouncing (500ms) +3. Optimize Graph API calls: + - Batch group resolution (max 100 IDs per batch) + - Add 100ms delay between sequential assignment POST calls +4. Add cache warming for target tenant groups +5. Create performance test: Restore 50 policies with 10 assignments each +6. Add tooltips/help text: + - Backup checkbox: "Captures group/user targeting and RBAC scope. Adds ~2-5 KB per policy." + - Group Mapping: "Map source groups to target groups for cross-tenant migrations." +7. Update documentation: Add "Assignments & Scope Tags" section to README + +**Acceptance Criteria**: +- Loading states visible during async operations +- Search responsive (no lag with 500+ groups) +- Performance benchmarks documented +- Tooltips clear and helpful +- README updated + +--- + +### Phase 9: Testing & QA +**Duration**: 2-3 hours +**Goal**: Comprehensive testing across all scenarios + +**Tasks**: +1. Manual QA checklist: + - ✅ Create backup with assignments checkbox (same tenant) + - ✅ Create backup without assignments checkbox + - ✅ View policy with assignments tab + - ✅ Restore to same tenant (auto-match groups) + - ✅ Restore to different tenant (group mapping wizard) + - ✅ Handle orphaned group IDs gracefully + - ✅ Skip assignments during group mapping + - ✅ Handle Graph API failures (assignments fetch, group resolution) +2. Browser test: `GroupMappingWizardTest` + - Navigate through multi-step wizard + - Search groups in dropdown + - Toggle skip checkboxes + - Verify mapping persistence +3. Load testing: 100+ policies with 20 assignments each +4. Staging deployment validation + +**Acceptance Criteria**: +- All manual QA scenarios pass +- Browser test passes on Chrome/Firefox +- Load test completes under 5 minutes +- Staging environment stable + +--- + +### Phase 10: Deployment & Documentation +**Duration**: 1-2 hours +**Goal**: Production-ready deployment + +**Tasks**: +1. Create deployment checklist: + - Run migrations on staging + - Verify no data loss + - Test on production-like data volume +2. Update `.specify/spec.md` with implementation notes +3. Create migration guide for existing backups (no retroactive assignment capture) +4. Add monitoring alerts: + - Assignment fetch failure rate > 10% + - Group resolution failure rate > 5% +5. Production deployment: + - Deploy to production via Dokploy + - Monitor logs for 24 hours + - Verify no Graph API rate limit issues + +**Acceptance Criteria**: +- Production deployment successful +- No critical errors in 24-hour monitoring window +- Documentation complete and accurate + +--- + +## Dependencies + +### Hard Dependencies (Required) +- Feature 001: Backup/Restore core infrastructure ✅ +- Graph Contract Registry ✅ +- Filament multi-step forms (built-in) ✅ + +### Soft Dependencies (Nice to Have) +- Feature 005: Bulk Operations (for bulk assignment backup) 🚧 + +--- + +## Risk Management + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|-----------| +| Graph API assignments endpoint slow | Medium | Medium | Async fetch with fail-soft, cache groups | +| Target tenant has 1000+ groups | High | Medium | Searchable dropdown with pagination, cache | +| Group IDs change across tenants | High | High | Group name-based fallback matching (future) | +| Scope Tag IDs don't exist in target | Medium | Low | Log warning, allow policy creation | +| Assignment restore fails mid-process | Medium | High | Per-assignment error handling, audit log | + +--- + +## Success Metrics + +### Functional +- ✅ Backup checkbox functional, assignments captured +- ✅ Policy View shows assignments tab with accurate data +- ✅ Group Mapping wizard handles 100+ groups smoothly +- ✅ Restore applies assignments with 90%+ success rate +- ✅ Audit logs record all mapping decisions + +### Technical +- ✅ Tests achieve 85%+ coverage for new code +- ✅ Graph API calls < 2 seconds average +- ✅ Group mapping UI responsive with 500+ groups +- ✅ Assignment restore completes in < 30 seconds for 20 policies + +### User Experience +- ✅ Admins can backup/restore assignments without manual intervention +- ✅ Cross-tenant migrations supported with clear group mapping UX +- ✅ Orphaned IDs handled gracefully with clear warnings + +--- + +## MVP Scope (Phases 1-4 + Core of Phase 5) + +**Estimated Duration**: 16-22 hours + +**Included**: +- US1: Backup with Assignments checkbox ✅ +- US2: Policy View with Assignments tab ✅ +- US3: Restore with Group Mapping (basic, same-tenant auto-match) ✅ + +**Excluded** (Post-MVP): +- US4: Restore Preview with detailed diff +- Scope Tags full support +- Performance optimizations +- Cross-tenant group mapping wizard + +**Rationale**: MVP proves core value (backup/restore assignments) while deferring complex cross-tenant mapping for iteration. + +--- + +## Full Implementation Estimate + +**Total Duration**: 30-40 hours + +**Phase Breakdown**: +- Phase 1: Setup & Database (2-3 hours) +- Phase 2: Graph API Integration (4-6 hours) +- Phase 3: US1 - Backup (4-5 hours) +- Phase 4: US2 - Policy View (3-4 hours) +- Phase 5: US3 - Group Mapping (6-8 hours) +- Phase 6: US4 - Restore Preview (2-3 hours) +- Phase 7: Scope Tags (2-3 hours) +- Phase 8: Polish (3-4 hours) +- Phase 9: Testing & QA (2-3 hours) +- Phase 10: Deployment (1-2 hours) + +**Parallel Opportunities**: +- Phase 2 (Graph services) + Phase 3 (Backup) can be split across developers +- Phase 4 (Policy View) independent of Phase 5 (Restore) +- Phase 7 (Scope Tags) can be developed alongside Phase 6 (Restore Preview) + +--- + +## Open Questions (For Review) + +1. **Smart Matching**: Should we support "smart matching" (group name similarity) for group mapping? + - **Recommendation**: Phase 2 feature, start with manual mapping MVP + +2. **Dynamic Groups**: How to handle dynamic groups (membership rules) - copy rules or skip? + - **Recommendation**: Skip initially, document as limitation, consider for future + +3. **Scope Tag Blocking**: Should Scope Tag warnings block restore or just warn? + - **Recommendation**: Warn only, allow restore to proceed (Graph API default behavior) + +4. **Assignment Filters**: Should we preserve assignment filters (device/user filters)? + - **Recommendation**: Yes, preserve all filter properties in JSON + +--- + +## Next Steps + +1. **Review**: Team review of plan.md, resolve open questions +2. **Research**: Create `research.md` with detailed technology decisions +3. **Data Model**: Create `data-model.md` with schema details +4. **Quickstart**: Create `quickstart.md` with developer setup +5. **Tasks**: Break down phases into granular tasks in `tasks.md` +6. **Implement**: Start with Phase 1 (Setup & Database) + +--- + +**Status**: Ready for Review +**Created**: 2025-12-22 +**Estimated Total Duration**: 30-40 hours (MVP: 16-22 hours) +**Next Document**: `research.md` diff --git a/specs/004-assignments-scope-tags/quickstart.md b/specs/004-assignments-scope-tags/quickstart.md new file mode 100644 index 0000000..71fedaa --- /dev/null +++ b/specs/004-assignments-scope-tags/quickstart.md @@ -0,0 +1,616 @@ +# Feature 004: Assignments & Scope Tags - Developer Quickstart + +## Overview +This guide helps developers quickly set up their environment and start working on the Assignments & Scope Tags feature. + +--- + +## Prerequisites + +- **Laravel Sail** installed and running +- **PostgreSQL** database via Sail +- **Microsoft Graph API** test tenant credentials +- **Redis** (optional, for caching - Sail includes it) + +--- + +## Quick Setup + +### 1. Start Sail Environment + +```bash +# Start all containers +./vendor/bin/sail up -d + +# Check status +./vendor/bin/sail ps +``` + +### 2. Run Migrations + +```bash +# Run new migrations for assignments +./vendor/bin/sail artisan migrate + +# Verify new columns exist +./vendor/bin/sail artisan tinker +>>> Schema::hasColumn('backup_items', 'assignments') +=> true +>>> Schema::hasColumn('restore_runs', 'group_mapping') +=> true +``` + +### 3. Seed Test Data (Optional) + +```bash +# Seed tenants, policies, and backup sets +./vendor/bin/sail artisan db:seed + +# Or create specific test data +./vendor/bin/sail artisan tinker +>>> $tenant = Tenant::factory()->create(['name' => 'Test Tenant']); +>>> $policy = Policy::factory()->for($tenant)->create(['type' => 'settingsCatalogPolicy']); +>>> $backupSet = BackupSet::factory()->for($tenant)->create(); +``` + +### 4. Configure Graph API Credentials + +```bash +# Copy example env +cp .env.example .env.testing + +# Add Graph API credentials +GRAPH_CLIENT_ID=your-client-id +GRAPH_CLIENT_SECRET=your-client-secret +GRAPH_TENANT_ID=your-test-tenant-id +``` + +--- + +## Development Workflow + +### Running Tests + +```bash +# Run all tests +./vendor/bin/sail artisan test + +# Run specific test file +./vendor/bin/sail artisan test tests/Feature/BackupWithAssignmentsTest.php + +# Run tests with filter +./vendor/bin/sail artisan test --filter=assignment + +# Run tests with coverage +./vendor/bin/sail artisan test --coverage +``` + +### Using Tinker for Debugging + +```bash +./vendor/bin/sail artisan tinker +``` + +**Common Tinker Commands**: + +```php +// Fetch assignments for a policy +$fetcher = app(AssignmentFetcher::class); +$assignments = $fetcher->fetch('tenant-id', 'policy-graph-id'); +dump($assignments); + +// Test group resolution +$resolver = app(GroupResolver::class); +$groups = $resolver->resolveGroupIds(['group-1', 'group-2'], 'tenant-id'); +dump($groups); + +// Test backup with assignments +$service = app(AssignmentBackupService::class); +$backupItem = $service->backup( + tenantId: 1, + policyId: 'abc-123', + includeAssignments: true +); +dump($backupItem->assignments); + +// Test group mapping +$restoreRun = RestoreRun::first(); +$restoreRun->addGroupMapping('source-group-1', 'target-group-1'); +$restoreRun->save(); +dump($restoreRun->group_mapping); + +// Clear cache +Cache::flush(); +``` + +--- + +## Manual Testing Scenarios + +### Scenario 1: Backup with Assignments (Happy Path) + +**Goal**: Verify assignments are captured during backup + +**Steps**: +1. Navigate to Backup creation form: `/tenants/{tenant}/backups/create` +2. Select Settings Catalog policies +3. ✅ Check "Include Assignments & Scope Tags" +4. Click "Create Backup" +5. Wait for backup job to complete + +**Verification**: +```bash +./vendor/bin/sail artisan tinker +>>> $backupItem = BackupItem::latest()->first(); +>>> dump($backupItem->assignments); +>>> dump($backupItem->metadata['assignment_count']); +``` + +**Expected**: +- `assignments` column populated with JSON array +- `metadata['assignment_count']` matches actual count +- Audit log entry: `backup.assignments.included` + +--- + +### Scenario 2: Policy View with Assignments Tab + +**Goal**: Verify assignments display correctly in UI + +**Steps**: +1. Navigate to Policy view: `/policies/{policy}` +2. Click "Assignments" tab + +**Verification**: +- Table shows assignments (Type, Name, Mode, ID) +- Orphaned IDs render as "Unknown Group (ID: {id})" with warning icon +- Scope Tags section shows tag names + IDs +- Empty state if no assignments + +**Edge Cases**: +- Policy with 0 assignments → Empty state +- Policy with orphaned group ID → Warning icon +- Policy without assignments metadata → Empty state + +--- + +### Scenario 3: Restore with Group Mapping (Cross-Tenant) + +**Goal**: Verify group mapping wizard works for cross-tenant restore + +**Setup**: +```bash +# Create source and target tenants +./vendor/bin/sail artisan tinker +>>> $sourceTenant = Tenant::factory()->create(['name' => 'Source Tenant']); +>>> $targetTenant = Tenant::factory()->create(['name' => 'Target Tenant']); + +# Create groups in both tenants (simulate via test data) +>>> Group::factory()->for($sourceTenant)->create(['graph_id' => 'source-group-1', 'display_name' => 'HR Team']); +>>> Group::factory()->for($targetTenant)->create(['graph_id' => 'target-group-1', 'display_name' => 'HR Department']); +``` + +**Steps**: +1. Navigate to Restore wizard: `/backups/{backup}/restore` +2. Select target tenant (different from source) +3. Click "Continue" to Restore Preview +4. **Group Mapping step should appear**: + - Source group: "HR Team (source-group-1)" + - Target group dropdown: searchable, populated with target tenant groups + - Select "HR Department" (target-group-1) +5. Click "Continue" +6. Review Restore Preview (should show mapped group) +7. Click "Restore" + +**Verification**: +```bash +./vendor/bin/sail artisan tinker +>>> $restoreRun = RestoreRun::latest()->first(); +>>> dump($restoreRun->group_mapping); +=> ["source-group-1" => "target-group-1"] +``` + +**Expected**: +- Group mapping step only appears when unresolved groups detected +- Dropdown searchable with 500+ groups +- Mapping persisted in `restore_runs.group_mapping` +- Audit log entries: `restore.group_mapping.applied` + +--- + +### Scenario 4: Handle Graph API Failures + +**Goal**: Verify fail-soft behavior when Graph API fails + +**Mock Graph Failure**: +```php +// In tests or local dev with Http::fake() +Http::fake([ + '*/assignments' => Http::response(null, 500), // Simulate failure +]); +``` + +**Steps**: +1. Create backup with "Include Assignments" checkbox +2. Observe behavior + +**Verification**: +```bash +./vendor/bin/sail artisan tinker +>>> $backupItem = BackupItem::latest()->first(); +>>> dump($backupItem->metadata['assignments_fetch_failed']); +=> true +>>> dump($backupItem->assignments); +=> null +``` + +**Expected**: +- Backup completes successfully (fail-soft) +- `assignments_fetch_failed` flag set to `true` +- Warning logged in `storage/logs/laravel.log` +- Audit log entry: `backup.assignments.fetch_failed` + +--- + +### Scenario 5: Skip Assignments in Group Mapping + +**Goal**: Verify "Skip" functionality in group mapping + +**Steps**: +1. Follow Scenario 3 setup +2. In Group Mapping step: + - Check "Skip" checkbox for one source group + - Map other groups normally +3. Complete restore + +**Verification**: +```bash +./vendor/bin/sail artisan tinker +>>> $restoreRun = RestoreRun::latest()->first(); +>>> dump($restoreRun->group_mapping); +=> ["source-group-1" => "target-group-1", "source-group-2" => "SKIP"] + +>>> dump($restoreRun->getSkippedAssignmentsCount()); +=> 2 # Assignments targeting source-group-2 were skipped +``` + +**Expected**: +- Skipped group has `"SKIP"` value in mapping JSON +- Restore runs without creating assignments for skipped groups +- Audit log entries: `restore.assignment.skipped` (per skipped) + +--- + +## Common Issues & Solutions + +### Issue 1: Migrations Fail + +**Error**: `SQLSTATE[42P01]: Undefined table: backup_items` + +**Solution**: +```bash +# Reset database and re-run migrations +./vendor/bin/sail artisan migrate:fresh --seed +``` + +--- + +### Issue 2: Graph API Rate Limiting + +**Error**: `429 Too Many Requests` + +**Solution**: +```bash +# Add retry logic with exponential backoff (already in GraphClient) +# Or reduce test load: +# - Use Http::fake() in tests +# - Add delays between API calls (100ms) +``` + +**Check Rate Limit Headers**: +```php +// In GraphClient.php +Log::info('Graph API call', [ + 'endpoint' => $endpoint, + 'retry_after' => $response->header('Retry-After'), + 'remaining_calls' => $response->header('X-RateLimit-Remaining'), +]); +``` + +--- + +### Issue 3: Cache Not Clearing + +**Error**: Stale group names in UI after updating tenant + +**Solution**: +```bash +# Clear all cache +./vendor/bin/sail artisan cache:clear + +# Clear specific cache keys +./vendor/bin/sail artisan tinker +>>> Cache::forget('groups:tenant-id:*'); +>>> Cache::forget('scope_tags:all'); +``` + +--- + +### Issue 4: Assignments Not Showing in UI + +**Checklist**: +1. ✅ Checkbox was enabled during backup? +2. ✅ `backup_items.assignments` column has data? (check via tinker) +3. ✅ Policy type is `settingsCatalogPolicy`? (others not supported in Phase 1) +4. ✅ Graph API call succeeded? (check logs for errors) + +**Debug**: +```bash +./vendor/bin/sail artisan tinker +>>> $backupItem = BackupItem::find(123); +>>> dump($backupItem->assignments); +>>> dump($backupItem->hasAssignments()); +>>> dump($backupItem->metadata['assignments_fetch_failed']); +``` + +--- + +### Issue 5: Group Mapping Dropdown Slow + +**Symptom**: Dropdown takes 5+ seconds to load with 500+ groups + +**Solutions**: +1. **Increase cache TTL**: + ```php + // In GroupResolver.php + Cache::remember("groups:{$tenantId}", 600, function () { ... }); // 10 min + ``` + +2. **Pre-warm cache**: + ```php + // In RestoreWizard.php mount() + public function mount() + { + // Cache groups when wizard opens + app(GroupResolver::class)->getAllForTenant($this->targetTenantId); + } + ``` + +3. **Add debouncing**: + ```php + // In Filament Select component + ->debounce(500) // Wait 500ms after typing + ``` + +--- + +## Performance Benchmarks + +### Target Metrics + +| Operation | Target | Actual (Measure) | +|-----------|--------|------------------| +| Assignment fetch (per policy) | < 2s | ___ | +| Group resolution (100 groups) | < 1s | ___ | +| Group mapping UI search | < 500ms | ___ | +| Assignment restore (20 policies) | < 30s | ___ | + +### Measuring Performance + +```bash +# Enable query logging +./vendor/bin/sail artisan tinker +>>> DB::enableQueryLog(); + +# Run operation +>>> $fetcher = app(AssignmentFetcher::class); +>>> $start = microtime(true); +>>> $assignments = $fetcher->fetch('tenant-id', 'policy-id'); +>>> $duration = microtime(true) - $start; +>>> dump("Duration: {$duration}s"); + +# Check queries +>>> dump(DB::getQueryLog()); +``` + +### Load Testing + +```bash +# Create 100 policies with assignments +./vendor/bin/sail artisan tinker +>>> for ($i = 0; $i < 100; $i++) { +>>> $policy = Policy::factory()->create(['type' => 'settingsCatalogPolicy']); +>>> $backupItem = BackupItem::factory()->for($policy->backupSet)->create([ +>>> 'assignments' => [/* 10 assignments */] +>>> ]); +>>> } + +# Measure restore time +>>> $start = microtime(true); +>>> $service = app(AssignmentRestoreService::class); +>>> $outcomes = $service->restoreBatch($policyIds, $groupMapping); +>>> $duration = microtime(true) - $start; +>>> dump("Restored 100 policies in {$duration}s"); +``` + +--- + +## Debugging Tools + +### 1. Laravel Telescope (Optional) + +```bash +# Install Telescope (if not already) +./vendor/bin/sail composer require laravel/telescope --dev +./vendor/bin/sail artisan telescope:install +./vendor/bin/sail artisan migrate + +# Access Telescope +# Navigate to: http://localhost/telescope +``` + +**Use Cases**: +- Monitor Graph API calls (Requests tab) +- Track slow queries (Queries tab) +- View cache hits/misses (Cache tab) +- Inspect jobs (Jobs tab) + +--- + +### 2. Laravel Debugbar (Installed) + +```bash +# Ensure Debugbar is enabled +DEBUGBAR_ENABLED=true # in .env +``` + +**Features**: +- Query count and duration per page load +- View all HTTP requests (including Graph API) +- Cache operations +- Timeline of execution + +--- + +### 3. Graph API Explorer + +**URL**: https://developer.microsoft.com/en-us/graph/graph-explorer + +**Use Cases**: +- Test Graph API endpoints manually +- Verify assignment response structure +- Debug authentication issues +- Check available permissions + +**Example Queries**: +``` +GET /deviceManagement/configurationPolicies/{id}/assignments +POST /directoryObjects/getByIds +GET /deviceManagement/roleScopeTags +``` + +--- + +## Test Data Factories + +### Factory: BackupItem with Assignments + +```php +// database/factories/BackupItemFactory.php +public function withAssignments(int $count = 5): static +{ + return $this->state(fn (array $attributes) => [ + 'assignments' => collect(range(1, $count))->map(fn ($i) => [ + 'id' => "assignment-{$i}", + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => "group-{$i}", + ], + 'intent' => 'apply', + ])->toArray(), + 'metadata' => array_merge($attributes['metadata'] ?? [], [ + 'assignment_count' => $count, + 'scope_tag_ids' => ['0'], + 'scope_tag_names' => ['Default'], + ]), + ]); +} + +// Usage +$backupItem = BackupItem::factory()->withAssignments(10)->create(); +``` + +--- + +### Factory: RestoreRun with Group Mapping + +```php +// database/factories/RestoreRunFactory.php +public function withGroupMapping(array $mapping = []): static +{ + return $this->state(fn (array $attributes) => [ + 'group_mapping' => $mapping ?: [ + 'source-group-1' => 'target-group-1', + 'source-group-2' => 'target-group-2', + ], + ]); +} + +// Usage +$restoreRun = RestoreRun::factory()->withGroupMapping([ + 'source-abc' => 'target-xyz', +])->create(); +``` + +--- + +## CI/CD Integration + +### GitHub Actions / Gitea CI (Example) + +```yaml +# .gitea/workflows/test.yml +name: Test Feature 004 + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Sail + run: | + docker-compose up -d + docker-compose exec -T laravel.test composer install + + - name: Run migrations + run: docker-compose exec -T laravel.test php artisan migrate --force + + - name: Run tests + run: docker-compose exec -T laravel.test php artisan test --filter=Assignment + + - name: Check code style + run: docker-compose exec -T laravel.test ./vendor/bin/pint --test +``` + +--- + +## Next Steps + +1. **Read Planning Docs**: + - [plan.md](plan.md) - Implementation phases + - [research.md](research.md) - Technology decisions + - [data-model.md](data-model.md) - Database schema + +2. **Start with Phase 1**: + - Run migrations + - Add model casts and accessors + - Write unit tests for model methods + +3. **Build Graph Services** (Phase 2): + - Implement `AssignmentFetcher` + - Implement `GroupResolver` + - Implement `ScopeTagResolver` + - Write unit tests with mocked Graph responses + +4. **Implement MVP** (Phase 3-4): + - Add backup checkbox + - Create assignment backup logic + - Add Policy View assignments tab + +--- + +## Additional Resources + +- **Laravel Docs**: https://laravel.com/docs/12.x +- **Filament Docs**: https://filamentphp.com/docs/4.x +- **Graph API Docs**: https://learn.microsoft.com/en-us/graph/api/overview +- **Pest Docs**: https://pestphp.com/docs/ + +--- + +**Status**: Quickstart Complete +**Next Document**: `tasks.md` (detailed task breakdown) diff --git a/specs/004-assignments-scope-tags/research.md b/specs/004-assignments-scope-tags/research.md new file mode 100644 index 0000000..c5810cd --- /dev/null +++ b/specs/004-assignments-scope-tags/research.md @@ -0,0 +1,566 @@ +# Feature 004: Assignments & Scope Tags - Research Notes + +## Overview +This document captures key technology decisions, API patterns, and implementation strategies for the Assignments & Scope Tags feature. + +--- + +## Research Questions & Answers + +### 1. How should we store assignments data? + +**Question**: Should assignments be stored as JSONB in `backup_items` table or as separate relational tables? + +**Options Evaluated**: + +| Approach | Pros | Cons | +|----------|------|------| +| **JSONB Column** | Simple schema, flexible structure, fast writes, matches Graph API response format | Complex queries, no foreign key constraints, larger row size | +| **Separate Tables** (`assignment` table) | Normalized, relational integrity, easier reporting | Complex migrations, slower backups, graph-to-relational mapping overhead | + +**Decision**: **JSONB Column** (`backup_items.assignments`) + +**Rationale**: +1. Assignments are **immutable snapshots** - we're not querying/filtering them frequently +2. Graph API returns assignments as nested JSON - direct storage avoids mapping +3. Backup/restore operations are write-heavy, not read-heavy (JSONB excels here) +4. Simplifies restore logic (no need to reconstruct JSON from relations) +5. Matches existing pattern for policy payloads in `backup_items.payload` (JSONB) + +**Implementation**: +```php +// Migration +Schema::table('backup_items', function (Blueprint $table) { + $table->json('assignments')->nullable()->after('metadata'); +}); + +// Model Cast +protected $casts = [ + 'assignments' => 'array', + // ... +]; + +// Accessor for assignment count +public function getAssignmentCountAttribute(): int +{ + return count($this->assignments ?? []); +} +``` + +**Trade-off**: If we need advanced assignment analytics (e.g., "show me all backups targeting group X"), we'll need to: +- Use PostgreSQL JSONB query operators (`@>`, `?`) +- Or add a read model (separate `assignment_analytics` table populated async) + +--- + +### 2. What's the best Graph API fallback strategy for assignments? + +**Question**: The spec mentions a fallback strategy for fetching assignments. What's the production-tested approach? + +**Problem Context**: +- Graph API behavior varies by policy template family +- Some policies return empty `/assignments` endpoint but have assignments via `$expand` +- Known issue with certain Settings Catalog template types + +**Strategy** (from spec FR-004.2): + +```php +class AssignmentFetcher +{ + public function fetch(string $tenantId, string $policyId): array + { + try { + // Primary: Direct assignments endpoint + $response = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments"); + + if (!empty($response['value'])) { + return $response['value']; + } + + // Fallback: Use $expand (slower but more reliable) + $response = $this->graph->get( + "/deviceManagement/configurationPolicies", + [ + '$filter' => "id eq '{$policyId}'", + '$expand' => 'assignments' + ] + ); + + return $response['value'][0]['assignments'] ?? []; + + } catch (GraphException $e) { + // Log warning, return empty (fail-soft) + Log::warning("Failed to fetch assignments for policy {$policyId}", [ + 'tenant_id' => $tenantId, + 'error' => $e->getMessage(), + 'request_id' => $e->getRequestId(), + ]); + + return []; + } + } +} +``` + +**Testing Strategy**: +- Mock both successful and failed responses +- Test with empty `value` array (triggers fallback) +- Test complete failure (returns empty, logs warning) + +**Edge Cases**: +- **Rate Limiting**: If primary call hits rate limit, fallback may also fail → log and continue +- **Timeout**: 30-second timeout on both calls → fail-soft, mark `assignments_fetch_failed: true` + +--- + +### 3. How to resolve group IDs to names efficiently? + +**Question**: We need to display group names in UI, but assignments only have group IDs. What's the best resolution strategy? + +**Graph API Options**: + +| Method | Endpoint | Pros | Cons | +|--------|----------|------|------| +| **Batch Resolution** | POST `/directoryObjects/getByIds` | Single request for 100+ IDs, stable API | Requires POST, batch size limit (1000) | +| **Filter Query** | GET `/groups?$filter=id in (...)` | Standard GET | Requires advanced query (not all tenants enabled), URL length limits | +| **Individual GET** | GET `/groups/{id}` per group | Simple | N+1 queries, slow for 50+ groups | + +**Decision**: **POST `/directoryObjects/getByIds` with Caching** + +**Rationale**: +1. Most reliable for large group counts (tested with 500+ groups) +2. Single request vs N requests +3. Works without advanced query requirements +4. Supports multiple object types (groups, users, devices) + +**Implementation**: +```php +class GroupResolver +{ + public function resolveGroupIds(array $groupIds, string $tenantId): array + { + // Check cache first (5 min TTL) + $cacheKey = "groups:{$tenantId}:" . md5(implode(',', $groupIds)); + + if ($cached = Cache::get($cacheKey)) { + return $cached; + } + + // Batch resolve + $response = $this->graph->post('/directoryObjects/getByIds', [ + 'ids' => $groupIds, + 'types' => ['group'], + ]); + + $resolved = collect($response['value'])->keyBy('id')->toArray(); + + // Handle orphaned IDs + $result = []; + foreach ($groupIds as $id) { + $result[$id] = $resolved[$id] ?? [ + 'id' => $id, + 'displayName' => null, // Will render as "Unknown Group (ID: {id})" + 'orphaned' => true, + ]; + } + + Cache::put($cacheKey, $result, now()->addMinutes(5)); + + return $result; + } +} +``` + +**Optimization**: Pre-warm cache when entering Group Mapping wizard (fetch all target tenant groups once). + +--- + +### 4. What's the UX pattern for Group Mapping with 500+ groups? + +**Question**: How do we make group mapping usable for large tenants? + +**UX Requirements** (from NFR-004.2): +- Must support 500+ groups +- Must be searchable +- Should feel responsive + +**Filament Solution**: **Searchable Select with Server-Side Filtering** + +```php +use Filament\Forms\Components\Select; + +Select::make('target_group_id') + ->label('Target Group') + ->searchable() + ->getSearchResultsUsing(fn (string $search) => + Group::query() + ->where('tenant_id', $this->targetTenantId) + ->where('display_name', 'ilike', "%{$search}%") + ->limit(50) + ->pluck('display_name', 'graph_id') + ) + ->getOptionLabelUsing(fn ($value): ?string => + Group::where('graph_id', $value)->first()?->display_name + ) + ->lazy() // Load options only when dropdown opens + ->debounce(500) // Wait 500ms after typing before searching +``` + +**Caching Strategy**: +```php +// Pre-warm cache when wizard opens +public function mount() +{ + // Cache all target tenant groups for 5 minutes + Cache::remember("groups:{$this->targetTenantId}", 300, function () { + return $this->graph->get('/groups?$select=id,displayName')['value']; + }); +} +``` + +**Alternative** (if Filament Select is slow): Use Livewire component with Alpine.js dropdown + AJAX search. + +--- + +### 5. How to handle assignment restore failures gracefully? + +**Question**: What if some assignments succeed and others fail during restore? + +**Requirements** (from FR-004.12, FR-004.13): +- Continue with remaining assignments (fail-soft) +- Log per-assignment outcome +- Report final status: "3 of 5 assignments restored" + +**DELETE-then-CREATE Pattern**: +```php +class AssignmentRestoreService +{ + public function restore(string $policyId, array $assignments, array $groupMapping): array + { + $outcomes = []; + + // Step 1: DELETE existing assignments (clean slate) + $existing = $this->graph->get("/deviceManagement/configurationPolicies/{$policyId}/assignments"); + + foreach ($existing['value'] as $assignment) { + try { + $this->graph->delete("/deviceManagement/configurationPolicies/{$policyId}/assignments/{$assignment['id']}"); + // 204 No Content = success + } catch (GraphException $e) { + Log::warning("Failed to delete assignment {$assignment['id']}", [ + 'error' => $e->getMessage(), + 'request_id' => $e->getRequestId(), + ]); + } + } + + // Step 2: CREATE new assignments (with mapped IDs) + foreach ($assignments as $assignment) { + // Apply group mapping + if (isset($assignment['target']['groupId'])) { + $sourceGroupId = $assignment['target']['groupId']; + + // Skip if marked in group mapping + if (isset($groupMapping[$sourceGroupId]) && $groupMapping[$sourceGroupId] === 'SKIP') { + $outcomes[] = ['status' => 'skipped', 'assignment' => $assignment]; + continue; + } + + // Replace with target group ID + $assignment['target']['groupId'] = $groupMapping[$sourceGroupId] ?? $sourceGroupId; + } + + try { + $response = $this->graph->post( + "/deviceManagement/configurationPolicies/{$policyId}/assignments", + $assignment + ); + // 201 Created = success + + $outcomes[] = ['status' => 'success', 'assignment' => $assignment]; + + // Audit log + AuditLog::create([ + 'action' => 'restore.assignment.created', + 'resource_type' => 'assignment', + 'resource_id' => $response['id'], + 'metadata' => $assignment, + ]); + + } catch (GraphException $e) { + $outcomes[] = [ + 'status' => 'failed', + 'assignment' => $assignment, + 'error' => $e->getMessage(), + 'request_id' => $e->getRequestId(), + ]; + + Log::error("Failed to restore assignment", [ + 'policy_id' => $policyId, + 'assignment' => $assignment, + 'error' => $e->getMessage(), + ]); + } + + // Rate limit protection: 100ms delay between POSTs + usleep(100000); + } + + return $outcomes; + } +} +``` + +**Outcome Reporting**: +```php +$successCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'success')); +$failedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'failed')); +$skippedCount = count(array_filter($outcomes, fn($o) => $o['status'] === 'skipped')); + +Notification::make() + ->title("Assignment Restore Complete") + ->body("{$successCount} of {$total} assignments restored. {$failedCount} failed, {$skippedCount} skipped.") + ->success() + ->send(); +``` + +**Why DELETE-then-CREATE vs PATCH**? +- PATCH requires knowing existing assignment IDs (not available from backup) +- DELETE-then-CREATE is idempotent (can rerun safely) +- Graph API doesn't support "upsert" for assignments + +--- + +### 6. How to handle Scope Tags? + +**Question**: Scope Tags are simpler than assignments (just an array of IDs in policy payload). How should we handle them? + +**Requirements**: +- Extract `roleScopeTagIds` array from policy payload during backup +- Resolve Scope Tag IDs to names for display +- Preserve IDs during restore (if they exist in target tenant) +- Warn if Scope Tag doesn't exist in target (don't block restore) + +**Implementation**: + +```php +// During Backup +class AssignmentBackupService +{ + public function backupScopeTags(array $policyPayload): array + { + $scopeTagIds = $policyPayload['roleScopeTagIds'] ?? ['0']; // Default Scope Tag + + // Resolve names (cached for 1 hour) + $scopeTags = $this->scopeTagResolver->resolve($scopeTagIds); + + return [ + 'scope_tag_ids' => $scopeTagIds, + 'scope_tag_names' => array_column($scopeTags, 'displayName'), + ]; + } +} + +// During Restore +class AssignmentRestoreService +{ + public function validateScopeTags(array $scopeTagIds, string $targetTenantId): array + { + $targetScopeTags = $this->scopeTagResolver->getAllForTenant($targetTenantId); + $targetScopeTagIds = array_column($targetScopeTags, 'id'); + + $matched = array_intersect($scopeTagIds, $targetScopeTagIds); + $missing = array_diff($scopeTagIds, $targetScopeTagIds); + + if (!empty($missing)) { + Log::warning("Some Scope Tags not found in target tenant", [ + 'missing' => $missing, + 'matched' => $matched, + ]); + } + + return [ + 'matched' => $matched, + 'missing' => $missing, + 'can_proceed' => true, // Always allow restore + ]; + } +} +``` + +**Caching**: +```php +class ScopeTagResolver +{ + public function resolve(array $scopeTagIds): array + { + return Cache::remember("scope_tags:all", 3600, function () { + return $this->graph->get('/deviceManagement/roleScopeTags?$select=id,displayName')['value']; + }); + } +} +``` + +**Display in UI**: +```php +// Restore Preview +Scope Tags: + ✅ 2 matched (Default, HR-Admins) + ⚠️ 1 not found in target (Finance-Admins) + +Note: Restore will proceed. Missing Scope Tags will be created automatically by Graph API or ignored. +``` + +--- + +### 7. What's the testing strategy? + +**Question**: How do we ensure reliability across all scenarios? + +**Test Pyramid**: + +``` + /\ + / \ + / UI \ Browser Tests (5) + /------\ - Group Mapping wizard flow + / Feature \ Feature Tests (10) + /----------\ - Backup with assignments + / Unit \ - Policy view rendering + /--------------\ - Restore with mapping + - Assignment fetch failures + + Unit Tests (15) + - AssignmentFetcher + - GroupResolver + - ScopeTagResolver + - Model accessors + - Service methods +``` + +**Key Test Scenarios**: + +1. **Unit Tests** (Fast, isolated): + ```php + it('fetches assignments with fallback on empty response', function () { + $fetcher = new AssignmentFetcher($this->mockGraph); + + // Mock primary call returning empty + $this->mockGraph + ->shouldReceive('get') + ->with('/deviceManagement/configurationPolicies/abc-123/assignments') + ->andReturn(['value' => []]); + + // Mock fallback call returning assignments + $this->mockGraph + ->shouldReceive('get') + ->with('/deviceManagement/configurationPolicies', [...]) + ->andReturn(['value' => [['assignments' => [...]]]]); + + $result = $fetcher->fetch('tenant-1', 'abc-123'); + + expect($result)->toHaveCount(3); + }); + ``` + +2. **Feature Tests** (Integration): + ```php + it('backs up policy with assignments when checkbox enabled', function () { + $tenant = Tenant::factory()->create(); + $policy = Policy::factory()->for($tenant)->create(); + + // Mock Graph API + Http::fake([ + '*/assignments' => Http::response(['value' => [...]]), + '*/roleScopeTags' => Http::response(['value' => [...]]), + ]); + + $backupItem = (new AssignmentBackupService)->backup( + tenantId: $tenant->id, + policyId: $policy->graph_id, + includeAssignments: true + ); + + expect($backupItem->assignments)->toHaveCount(3); + expect($backupItem->metadata['assignment_count'])->toBe(3); + }); + ``` + +3. **Browser Tests** (E2E): + ```php + it('allows group mapping during restore wizard', function () { + $page = visit('/restore/wizard'); + + $page->assertSee('Group Mapping Required') + ->fill('source-group-abc-123', 'target-group-xyz-789') + ->click('Continue') + ->assertSee('Restore Preview') + ->click('Restore') + ->assertSee('Restore Complete'); + + $restoreRun = RestoreRun::latest()->first(); + expect($restoreRun->group_mapping)->toHaveKey('source-group-abc-123'); + }); + ``` + +**Manual QA Checklist**: +- [ ] Create backup with assignments (same tenant) +- [ ] Create backup without assignments +- [ ] View policy with assignments tab +- [ ] Restore to same tenant (auto-match groups) +- [ ] Restore to different tenant (group mapping wizard) +- [ ] Handle orphaned group IDs gracefully +- [ ] Skip assignments during group mapping +- [ ] Handle Graph API failures (assignments fetch, group resolution) + +--- + +## Technology Stack Summary + +| Component | Technology | Justification | +|-----------|-----------|---------------| +| **Assignment Storage** | PostgreSQL JSONB | Immutable snapshots, matches Graph API format | +| **Graph API Fallback** | Primary + Fallback pattern | Production-tested reliability | +| **Group Resolution** | POST `/directoryObjects/getByIds` + Cache | Batch efficiency, 5min TTL | +| **Group Mapping UX** | Filament Searchable Select + Debounce | Supports 500+ groups, responsive | +| **Restore Pattern** | DELETE-then-CREATE | Idempotent, no ID tracking needed | +| **Scope Tag Handling** | Extract from payload + Cache | Simple, 1hr TTL | +| **Error Handling** | Fail-soft + Per-item logging | Graceful degradation | +| **Caching** | Laravel Cache (Redis/File) | 5min groups, 1hr scope tags | + +--- + +## Performance Benchmarks + +**Target Metrics**: +- Assignment fetch: < 2 seconds per policy (with fallback) +- Group resolution: < 1 second for 100 groups (batched) +- Group mapping UI: < 500ms search response with 500+ groups (debounced) +- Assignment restore: < 30 seconds for 20 policies (sequential with 100ms delay) + +**Optimization Opportunities**: +1. Pre-warm group cache when wizard opens +2. Batch assignment POSTs (if Graph supports batch endpoint - check docs) +3. Use queues for large restores (20+ policies) +4. Add progress polling for long-running restores + +--- + +## Open Questions (For Implementation) + +1. **Smart Matching**: Should we auto-suggest group mappings based on name similarity? + - **Defer**: Manual mapping MVP, consider for Phase 2 + +2. **Dynamic Groups**: How to handle membership rules? + - **Defer**: Skip initially, document as limitation + +3. **Assignment Filters**: Do we preserve device/user filters? + - **Yes**: Store entire assignment object in JSONB + +4. **Batch API**: Does Graph support batch assignment POST? + - **Research**: Check Graph API docs for `/batch` endpoint support + +--- + +**Status**: Research Complete +**Next Document**: `data-model.md` diff --git a/specs/004-assignments-scope-tags/spec.md b/specs/004-assignments-scope-tags/spec.md new file mode 100644 index 0000000..2011a3c --- /dev/null +++ b/specs/004-assignments-scope-tags/spec.md @@ -0,0 +1,542 @@ +# Feature 004: Assignments & Scope Tags for Settings Catalog Policies + +## Overview +Extend backup and restore functionality to include **Assignments** (group/user/device targeting) and **Scope Tags** for Settings Catalog policies. This ensures complete policy state capture and enables cross-tenant migrations with group mapping. + +## Problem Statement +Currently, TenantPilot backs up only the Settings Catalog policy configuration itself. However, **Assignments** (which groups/users/devices the policy applies to) and **Scope Tags** (RBAC-based policy visibility) are critical metadata that make a policy actionable. + +**Without this feature:** +- Restored policies have no assignments → must be manually configured +- Cross-tenant migrations lose targeting context +- Scope Tags are not preserved → RBAC-scoped admins may lose access +- Backup previews don't show assignment counts or scope + +**With this feature:** +- Complete policy state backup (config + assignments + scope tags) +- Cross-tenant restore with intelligent group mapping +- Clear preview/diff of assignment changes +- Optional inclusion (lightweight backups possible) + +## Goals +- **Primary**: Backup and restore assignments for Settings Catalog policies +- **Secondary**: Include Scope Tags in backup metadata +- **Tertiary**: Group mapping wizard for cross-tenant restores +- **Non-Goal**: Assignments for non-Settings Catalog types (future features 006-009) + +## Scope +- **Policy Types**: `settingsCatalogPolicy` only (initially) +- **Graph Endpoints**: + - GET `/deviceManagement/configurationPolicies/{id}/assignments` + - POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments) + - DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback) + - GET `/deviceManagement/roleScopeTags` (for reference data) + - GET `/deviceManagement/assignmentFilters` (for filter names) +- **Backup Behavior**: Optional at capture time with separate checkboxes ("Include assignments", "Include scope tags") on Add Policies and Capture Snapshot actions (defaults: true) +- **Restore Behavior**: With group mapping UI for unresolved group IDs + +--- + +## User Stories + +### User Story 1 - Capture Assignments & Scope Tags (Priority: P1) + +**As an admin**, I want to optionally include assignments and scope tags when capturing policy snapshots or adding policies to a Backup Set, so that I have complete policy state for migration or disaster recovery. + +**Acceptance Criteria:** +1. **Given** I add policies to a Backup Set (or capture a snapshot from the Policy view), + **When** I enable "Include assignments" and/or "Include scope tags", + **Then** the capture stores: + - Assignments on the PolicyVersion (include/exclude targets + filters) + - Scope tags on the PolicyVersion as `{ids, names}` + - A BackupItem linked via `policy_version_id` that copies assignments for restore + +2. **Given** I create a Backup Set, + **When** I complete the form, + **Then** no assignments/scope tags checkbox appears on that screen (selection happens when adding policies). + +3. **Given** I disable either checkbox, + **When** the capture completes, + **Then** the corresponding PolicyVersion fields are `null` and the BackupItem is created without those data. + +--- + +### User Story 2 - Policy Version View with Assignments (Priority: P1) + +**As an admin**, I want to see a policy version's captured assignments and scope tags, so I understand targeting and visibility at that snapshot. + +**Acceptance Criteria:** +1. **Given** I view a Settings Catalog Policy Version, + **When** assignments were captured, + **Then** I see: + - Include/Exclude group targets with group display name (or "Unknown Group (ID: ...)") + - Filter name (if present) with filter mode (include/exclude) + - Scope tags list from the version + +2. **Given** assignments were not captured for this version, + **When** I open the assignments panel, + **Then** I see "Assignments were not captured for this version." + +3. **Given** scope tags were not captured, + **When** I view the version, + **Then** I see a "Scope tags not captured" empty state. + +--- + +### User Story 3 - Restore with Group Mapping (Priority: P1) + +**As an admin**, I want to map source tenant groups to target tenant groups during restore, so I can migrate policies across tenants without manual re-assignment. + +**Acceptance Criteria:** +1. **Given** I restore a Backup Item with assignments to a different tenant, + **When** the restore preview detects unresolved group IDs, + **Then** the wizard shows a "Group Mapping" step with: + - Source group name and ID + - Target tenant groups dropdown (searchable) + - "Skip assignment" checkbox per group + +2. **Given** I map 3 source groups to target groups, + **When** I confirm the restore, + **Then** the restored policy has assignments pointing to the mapped target groups + +3. **Given** I choose "Skip assignment" for 2 groups, + **When** the restore completes, + **Then** those 2 assignments are NOT created (only mapped groups restored) + +4. **Given** all source groups exist in target tenant (same IDs), + **When** the restore runs, + **Then** the Group Mapping step is skipped (auto-matched) + +--- + +### User Story 4 - Restore Preview with Assignment Diff (Priority: P2) + +**As an admin**, I want to see assignment changes in the restore preview, so I know what will be modified before executing. + +**Acceptance Criteria:** +1. **Given** I preview a restore that includes assignments, + **When** the target policy has different assignments, + **Then** the preview shows: + - "Assignments: 3 will be added, 1 removed, 2 unchanged" + - Expandable diff: Added (green), Removed (red), Unchanged (gray) + +2. **Given** the target policy has no existing assignments, + **When** I preview the restore, + **Then** the preview shows "Assignments: 5 will be created" + +3. **Given** I restore a backup without assignments, + **When** I preview the restore, + **Then** the assignment section shows "Not included in backup" + +--- + +## Functional Requirements + +### Backup & Storage + +**FR-004.1**: System MUST provide separate checkboxes "Include assignments" and "Include scope tags" on: +- Add Policies to Backup Set action +- Capture snapshot action in Policy view +Defaults: checked. Backup Set creation form MUST NOT show these checkboxes. + +**FR-004.2**: When assignments are included, system MUST fetch assignments using fallback strategy: +1. Try: `/deviceManagement/configurationPolicies/{id}/assignments` +2. If empty/fails: Try `$expand=assignments` on policy fetch +3. Continue capture with assignments `null` on failure (fail-soft) and set `assignments_fetch_failed: true` in PolicyVersion metadata. + - This flag covers any failure during assignment capture/enrichment (fetch, group resolution, filter resolution). + +**FR-004.3**: System MUST enrich assignments with: +- Group display name + orphaned flag via `/directoryObjects/getByIds` +- Assignment filter name via `/deviceManagement/assignmentFilters` +- Preserve target type (include/exclude) and filter mode (`deviceAndAppManagementAssignmentFilterType`) +- If filter name lookup fails or filter ID is unknown, keep filter ID + mode, omit the name, and continue capture (UI displays filter ID when name is missing). + +**FR-004.4**: System MUST store assignments and scope tags on PolicyVersion: +- `policy_versions.assignments` (array, nullable) +- `policy_versions.scope_tags` as `{ids: [], names: []}` (nullable) +- hashes for deduplication (`assignments_hash`, `scope_tags_hash`) +BackupItem MUST link to PolicyVersion via `policy_version_id` and copy assignments for restore. + +**FR-004.5**: PolicyVersion metadata MUST include capture flags (see Data Model). BackupItem metadata MAY mirror these flags for display/audit, but PolicyVersion is the source of truth. Assignment counts are derived from `assignments` at display time. + +### UI Display + +**FR-004.6**: Policy Version view MUST show an assignments panel for Settings Catalog versions displaying: +- Include/Exclude targets with group display name or "Unknown Group (ID: ...)" +- Assignment filter name + filter mode (include/exclude) when present +- Scope Tags section +- Empty state if assignments or scope tags were not captured + +**FR-004.7**: Backup Set items table MUST show assignment count (derived from `backup_items.assignments`) and scope tag names from the linked PolicyVersion. + +**FR-004.8**: System MUST render orphaned group IDs as "Unknown Group (ID: {id})" with warning icon. + +**Terminology**: +- **Orphaned group ID**: A group ID referenced in assignments that cannot be resolved in the source tenant during capture. +- **Unresolved group ID**: A group ID not found in the target tenant during restore mapping. +- UI SHOULD render both as "Unknown Group (ID: ...)" with warning styling. + +### Restore with Group Mapping + +**FR-004.9**: Restore preview MUST detect unresolved (target-missing) group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved. + +**FR-004.10**: When unresolved groups exist, system MUST inject a "Group Mapping" step in the restore wizard showing: +- Source group (name from backup metadata or ID if name unavailable) +- Target group dropdown (searchable, populated from target tenant) +- "Skip" checkbox + +**FR-004.11**: System MUST persist group mapping selections in RestoreRun metadata for audit and rerun purposes. + +**FR-004.12**: When restoring assignments, system MUST: +1. Replace source group IDs with mapped target group IDs in assignment objects +2. Skip assignments marked "Skip" in group mapping +3. Preserve include/exclude intent and filters +4. Execute restore via assign action when supported: + - Step 1: POST `/assign` with `{ assignments: [...] }` to replace assignments + - Step 2 (fallback): If `/assign` is unsupported, use DELETE-then-CREATE: + - GET existing assignments from target policy + - DELETE each existing assignment (via DELETE `/assignments/{id}`) + - POST each new/mapped assignment (via POST `/assignments`) +5. Handle failures gracefully: + - 204 No Content on DELETE = success + - 201 Created on POST = success + - Log request-id/client-request-id on any failure +6. Continue with remaining assignments if one fails (fail-soft) +7. Restore is best-effort: no transactional rollback between DELETE and POST. If DELETE succeeds but POST fails, record a failed outcome, mark the restore as partial, and allow retry. + +**FR-004.13**: System MUST handle assignment restore failures gracefully: +- Log per-assignment outcome (success/skip/failure) +- Continue with remaining assignments +- Report final status: "3 of 5 assignments restored" + +**FR-004.14**: System MUST write audit log entries: +- `backup.assignments.included` (when checkbox enabled) +- `restore.group_mapping.applied` (with mapping details) +- `restore.assignment.created` (per assignment) +- `restore.assignment.skipped` (per skipped) + +### Scope Tags + +**FR-004.15**: System MUST extract Scope Tag IDs from policy payload's `roleScopeTagIds` array during backup. + +**FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour). + +**FR-004.17**: During restore, system MUST preserve Scope Tag IDs that exist in the target tenant. If a Scope Tag ID is missing: +- Log a warning +- Proceed without that tag (best-effort, Graph API default behavior) + +**FR-004.18**: Restore preview MUST show Scope Tag diff: "Scope Tags: 2 matched, 1 not found in target tenant". + +--- + +## Non-Functional Requirements + +**NFR-004.1**: Assignment fetching MUST NOT block capture actions (Add Policies / Capture Snapshot). Use async or fail-soft behavior. + +**NFR-004.2**: Group mapping UI MUST support search/filter for tenants with 500+ groups. + +**NFR-004.3**: Assignment restore MUST batch API calls (if Graph supports batch, else sequential with 100ms delay). + +**NFR-004.4**: System MUST cache target tenant group list for 5 minutes during restore wizard session. + +--- + +## Data Model Changes + +### Migration: `policy_versions` assignments + scope tags + +```php +Schema::table('policy_versions', function (Blueprint $table) { + $table->json('assignments')->nullable()->after('metadata'); + $table->json('scope_tags')->nullable()->after('assignments'); + $table->string('assignments_hash', 64)->nullable()->after('scope_tags'); + $table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash'); + $table->index('assignments_hash'); + $table->index('scope_tags_hash'); +}); +``` + +### Migration: `backup_items` policy_version_id + +```php +Schema::table('backup_items', function (Blueprint $table) { + $table->foreignId('policy_version_id')->nullable()->constrained('policy_versions'); +}); +``` + +### Migration: `backup_items` assignments copy + +```php +Schema::table('backup_items', function (Blueprint $table) { + $table->json('assignments')->nullable()->after('metadata'); + // copy of PolicyVersion assignments for restore safety +}); +``` + +### Migration: `restore_runs` table extension + +```php +Schema::table('restore_runs', function (Blueprint $table) { + $table->json('group_mapping')->nullable()->after('results'); + // stores: {"source-group-id": "target-group-id", ...} +}); +``` + +### `policy_versions.scope_tags` JSONB schema + +```json +{ + "ids": ["0", "123"], + "names": ["Default", "HR"] +} +``` + +### `policy_versions.metadata` JSONB schema + +```json +{ + "has_assignments": true, + "has_scope_tags": true, + "has_orphaned_assignments": false, + "assignments_fetch_failed": false +} +``` + +BackupItem metadata MAY include the same flags copied from the PolicyVersion for display/audit, but PolicyVersion is the source of truth. + +--- + +## Graph API Integration + +### Endpoints to Add (Production-Tested Strategies) + +1. **GET Assignments (with Fallback Strategy)** + - **Primary**: `/deviceManagement/configurationPolicies/{id}/assignments` + - Returns: `{ value: [assignment objects] }` + - Contract: `type_family: [#microsoft.graph.deviceManagementConfigurationPolicyAssignment]` + - **Fallback** (if primary fails/returns empty): + - `/deviceManagement/configurationPolicies?$expand=assignments&$filter=id eq '{id}'` + - Client-side filter to extract assignments + - **Reason**: Known Graph API quirks with assignment expansion on certain template families + +2. **Assignment Apply** (Assign action + fallback) + + - **POST** `/deviceManagement/configurationPolicies/{id}/assign` + - Body: `{ "assignments": [ ... ] }` + - Returns: 200/204 on success (no per-assignment IDs) + - Example: + ```json + { + "assignments": [ + { + "target": { + "@odata.type": "#microsoft.graph.groupAssignmentTarget", + "groupId": "abc-123-def" + }, + "intent": "apply" + } + ] + } + ``` + + - **Fallback** (when `/assign` is unsupported): + - **GET** `/deviceManagement/configurationPolicies/{id}/assignments` + - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` + - **POST** `/deviceManagement/configurationPolicies/{id}/assignments` (single assignment object) + + - **Restore Strategy**: Prefer `/assign`; if unsupported, delete existing assignments then POST new ones (best-effort; record outcomes, no transactional rollback). + +3. **POST** `/directoryObjects/getByIds` (Stable Group Resolution) + - Body: `{ "ids": ["id1", "id2"], "types": ["group"] }` + - Returns: `{ value: [{ id, displayName, ... }] }` + - **Reason**: More stable than `$filter=id in (...)` which can fail with advanced query requirements + - Example: + ```json + { + "ids": ["abc-123", "def-456"], + "types": ["group"] + } + ``` + +4. **GET** `/deviceManagement/roleScopeTags?$select=id,displayName` + - For Scope Tag resolution (cache 1 hour) + - Scope Tag IDs also available in policy payload's `roleScopeTagIds` array + +5. **GET** `/deviceManagement/assignmentFilters?$select=id,displayName` + - For assignment filter name resolution (cache 1 hour) + - Filter IDs in assignments: `deviceAndAppManagementAssignmentFilterId` + - Filter mode: `deviceAndAppManagementAssignmentFilterType` (include/exclude) + +### Graph Contract Updates + +Add to `config/graph_contracts.php`: + +```php +'settingsCatalogPolicy' => [ + // ... existing config + + // 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', // array in policy payload +], +``` + +--- + +## UI Mockups (Wireframe Descriptions) + +### Policy Version View - Assignments Panel + +``` +[General] [Settings] [JSON] + +Assignments (5) +┌─────────────────────────────────────────────────┐ +│ Type │ Name │ Filter │ ID │ +├─────────┼───────────────────┼─────────┼─────────┤ +│ Include group │ All Users │ Test (include) │ abc-123 │ +│ Exclude group │ Contractors │ - │ def-456 │ +└─────────────────────────────────────────────────┘ + +Scope Tags (2) + • Default (ID: 0) + • HR-Admins (ID: 123) +``` + +### Add Policies to Backup Set - Checkboxes + +``` +Add Policies to Backup Set +───────────────────────── +Select Policies: [Settings Catalog: 15 selected] + +☑ Include assignments + Captures include/exclude targeting and filters. + +☑ Include scope tags + Captures policy scope tag IDs. + +[Cancel] [Add Policies] +``` + +### Restore Wizard - Group Mapping Step + +``` +Restore Preview > Group Mapping > Confirm + +Group Mapping Required +Some groups from source tenant don't exist in target tenant. + +┌────────────────────────────────────────────────────────┐ +│ Source Group │ Target Group │ Action │ +├───────────────────────┼───────────────────────┼────────┤ +│ All Users (abc-123) │ [Select target group] │ ☐ Skip │ +│ HR Team (def-456) │ HR Department │ │ +│ Contractors (ghi-789) │ [Select target group] │ ☑ Skip │ +└────────────────────────────────────────────────────────┘ + +[Back] [Continue] +``` + +--- + +## Testing Strategy + +### Unit Tests +- `AssignmentFetcherTest`: Mock Graph responses, test parsing +- `GroupMapperTest`: Test ID resolution, mapping logic +- `ScopeTagResolverTest`: Test caching, name resolution +- `AssignmentFilterResolverTest`: Test caching and ID filtering + +### Feature Tests +- `BackupWithAssignmentsConsistencyTest`: PolicyVersion as source of truth +- `VersionCaptureWithAssignmentsTest`: Snapshot capture with assignments/scope tags +- `PolicyVersionViewAssignmentsTest`: UI rendering, orphaned IDs, filters +- `RestoreGroupMappingTest`: Wizard flow, mapping persistence +- `RestoreAssignmentApplicationTest`: Graph API calls, outcomes + +### Manual QA +- Add policies to backup set with/without assignments and scope tags +- Capture snapshot with/without assignments and scope tags +- Restore to same tenant (auto-match groups) +- Restore to different tenant (group mapping wizard) +- Handle orphaned group IDs gracefully + +--- + +## Rollout Plan + +### Phase 1: Backup with Assignments (MVP) +- Add checkboxes on Add Policies + Capture Snapshot actions +- Fetch assignments from Graph +- Store on PolicyVersion (copy assignments to BackupItem) +- Display in Policy Version view (read-only) +- **Duration**: ~8-12 hours + +### Phase 2: Restore with Group Mapping +- Add Group Mapping wizard step +- Implement ID resolution +- Apply assignments on restore +- **Duration**: ~12-16 hours + +### Phase 3: Scope Tags +- Resolve Scope Tag names +- Display in Policy Version view +- Handle restore warnings +- **Duration**: ~4-6 hours + +### Phase 4: Future Extensions +- Feature 006: Extend to `compliancePolicy` +- Feature 007: Extend to `deviceConfiguration` +- Feature 008: Extend to Conditional Access +- Feature 009: Assignment analytics/reporting + +--- + +## Dependencies +- Feature 001: Backup/Restore core (✅ complete) +- Graph Contract Registry (✅ complete) +- Filament multi-step forms (built-in) + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Graph API assignments endpoint slow/fails | Fail-soft with warning | +| Target tenant has 1000+ groups | Searchable dropdown with pagination | +| Group IDs change across tenants | Group name-based matching fallback | +| Scope Tag IDs don't exist in target | Log warning, allow policy creation | + +--- + +## Success Criteria + +1. ✅ Capture checkboxes functional, assignments captured +2. ✅ Policy Version view shows assignments widget with accurate data +3. ✅ Group Mapping wizard handles 100+ groups smoothly +4. ✅ Restore applies assignments with 90%+ success rate +5. ✅ Audit logs record all mapping decisions +6. ✅ Tests achieve 85%+ coverage for new code + +--- + +## Open Questions +1. Should we support "smart matching" (group name similarity) for group mapping? +2. How to handle dynamic groups (membership rules) - copy rules or skip? +3. Should Scope Tag warnings block restore or just warn? + +--- + +**Status**: Draft for Review +**Created**: 2025-12-22 +**Author**: AI + Ahmed +**Next Steps**: Review → Plan → Tasks diff --git a/specs/004-assignments-scope-tags/tasks.md b/specs/004-assignments-scope-tags/tasks.md new file mode 100644 index 0000000..e464608 --- /dev/null +++ b/specs/004-assignments-scope-tags/tasks.md @@ -0,0 +1,853 @@ +# Feature 004: Assignments & Scope Tags - Task Breakdown + +## Overview +This document breaks down the implementation plan into granular, actionable tasks organized by phase and user story. + +**Total Estimated Tasks**: ~60 tasks +**MVP Scope**: Tasks marked with ⭐ (updated) +**Full Implementation**: All tasks (~30-40 hours) + +--- + +## Plan Updates (Implementation Reality) +- Assignments + scope tags are stored on PolicyVersion; BackupItem links to PolicyVersion and copies assignments. +- Include assignments/scope tags checkboxes live on Add Policies to Backup Set and Capture Snapshot actions (not on Backup Set creation). +- Policy assignments UI moved to Policy Version view via Livewire widget. +- Assignment filters resolved via `/deviceManagement/assignmentFilters` and displayed with include/exclude mode. + +--- + +## Phase 1: Setup & Database (Foundation) + +**Duration**: 2-3 hours +**Dependencies**: None +**Parallelizable**: No (sequential setup) + +### Tasks + +**1.1** [X] ⭐ Create migration: `add_assignments_to_backup_items` +- File: `database/migrations/xxxx_add_assignments_to_backup_items.php` +- Add `assignments` JSONB column after `metadata` +- Make nullable +- Write reversible `down()` method +- Test: `php artisan migrate` and `migrate:rollback` + +**1.2** [X] ⭐ Create migration: `add_group_mapping_to_restore_runs` +- File: `database/migrations/xxxx_add_group_mapping_to_restore_runs.php` +- Add `group_mapping` JSONB column after `results` +- Make nullable +- Write reversible `down()` method +- Test: `php artisan migrate` and `migrate:rollback` + +**1.3** [X] ⭐ Update `BackupItem` model with assignments cast +- Add `'assignments' => 'array'` to `$casts` +- Add `assignments` to `$fillable` +- Test: Create BackupItem with assignments, verify cast works + +**1.4** [X] ⭐ Add `BackupItem` assignment accessor methods +- `getAssignmentCountAttribute(): int` +- `hasAssignments(): bool` +- `getGroupIdsAttribute(): array` +- `getScopeTagIdsAttribute(): array` +- `getScopeTagNamesAttribute(): array` +- `hasOrphanedAssignments(): bool` +- `assignmentsFetchFailed(): bool` + +**1.5** [X] ⭐ Add `BackupItem` scope: `scopeWithAssignments()` +- Filter policies with non-null assignments +- Use `whereNotNull('assignments')` and `whereRaw('json_array_length(assignments) > 0')` + +**1.6** [X] ⭐ Update `RestoreRun` model with group_mapping cast +- Add `'group_mapping' => 'array'` to `$casts` +- Add `group_mapping` to `$fillable` +- Test: Create RestoreRun with group_mapping, verify cast works + +**1.7** [X] ⭐ Add `RestoreRun` group mapping helper methods +- `hasGroupMapping(): bool` +- `getMappedGroupId(string $sourceGroupId): ?string` +- `isGroupSkipped(string $sourceGroupId): bool` +- `getUnmappedGroupIds(array $sourceGroupIds): array` +- `addGroupMapping(string $sourceGroupId, string $targetGroupId): void` + +**1.8** [X] ⭐ Add `RestoreRun` assignment outcome methods +- `getAssignmentRestoreOutcomes(): array` +- `getSuccessfulAssignmentsCount(): int` +- `getFailedAssignmentsCount(): int` +- `getSkippedAssignmentsCount(): int` + +**1.9** [X] ⭐ Update `config/graph_contracts.php` with assignments endpoints +- Add `assignments_list_path` (GET) +- Add `assignments_create_path` (POST `/assign` for settingsCatalogPolicy) +- Add `assignments_delete_path` (DELETE) +- Add `supports_scope_tags: true` +- Add `scope_tag_field: 'roleScopeTagIds'` + +**1.10** [X] ⭐ Write unit tests: `BackupItemTest` +- Test assignment accessors +- Test scope `withAssignments()` +- Test metadata helpers (scope tags, orphaned flags) +- Expected: 100% coverage for new methods + +**1.11** [X] ⭐ Write unit tests: `RestoreRunTest` +- Test group mapping helpers +- Test assignment outcome methods +- Test `addGroupMapping()` persistence +- Expected: 100% coverage for new methods + +**1.12** [X] Run Pint: Format all new code +- `./vendor/bin/pint database/migrations/` +- `./vendor/bin/pint app/Models/BackupItem.php` +- `./vendor/bin/pint app/Models/RestoreRun.php` + +**1.13** [X] Verify tests pass +- Run: `php artisan test --filter=BackupItem` +- Run: `php artisan test --filter=RestoreRun` +- Expected: All green + +**1.14** [X] ⭐ Add migration: `add_assignments_to_policy_versions` +- Add `assignments`, `scope_tags`, `assignments_hash`, `scope_tags_hash` +- Add indexes on hashes +- Reversible `down()` method + +**1.15** [X] ⭐ Add migration: `add_policy_version_id_to_backup_items` +- Add nullable `policy_version_id` FK to `policy_versions` +- Reversible `down()` method + +**1.16** [X] ⭐ Update `PolicyVersion` model casts +- Add casts for `assignments` and `scope_tags` + +**1.17** [X] ⭐ Add `BackupItem` relation to PolicyVersion +- `policyVersion(): BelongsTo` + +--- + +## Phase 2: Graph API Integration (Core Services) + +**Duration**: 4-6 hours +**Dependencies**: Phase 1 complete +**Parallelizable**: Yes (services independent) + +### Tasks + +**2.1** [X] ⭐ Create service: `AssignmentFetcher` +- File: `app/Services/Graph/AssignmentFetcher.php` +- Method: `fetch(string $tenantId, string $policyId): array` +- Implement primary endpoint: GET `/assignments` +- Implement fallback: GET with `$expand=assignments` +- Return empty array on failure (fail-soft) +- Log warnings with request IDs + +**2.2** [X] ⭐ Add error handling to `AssignmentFetcher` +- Catch `GraphException` +- Log: tenant_id, policy_id, error message, request_id +- Return empty array (don't throw) +- Set flag for caller: `assignments_fetch_failed` + +**2.3** [X] ⭐ Write unit test: `AssignmentFetcherTest::primary_endpoint_success` +- Mock Graph response with assignments +- Assert returned array matches response +- Assert no fallback called + +**2.4** [X] ⭐ Write unit test: `AssignmentFetcherTest::fallback_on_empty_response` +- Mock primary returning empty array +- Mock fallback returning assignments +- Assert fallback called +- Assert assignments returned + +**2.5** [X] ⭐ Write unit test: `AssignmentFetcherTest::fail_soft_on_error` +- Mock both endpoints throwing `GraphException` +- Assert empty array returned +- Assert warning logged + +**2.6** [X] Create service: `GroupResolver` +- File: `app/Services/Graph/GroupResolver.php` +- Method: `resolveGroupIds(array $groupIds, string $tenantId): array` +- Implement: POST `/directoryObjects/getByIds` +- Return keyed array: `['group-id' => ['id', 'displayName', 'orphaned']]` +- Handle orphaned IDs (not in response) + +**2.7** [X] Add caching to `GroupResolver` +- Cache key: `"groups:{$tenantId}:" . md5(implode(',', $groupIds))` +- TTL: 5 minutes +- Use `Cache::remember()` + +**2.8** [X] Write unit test: `GroupResolverTest::resolves_all_groups` +- Mock Graph response with all group IDs +- Assert all resolved with names +- Assert `orphaned: false` + +**2.9** [X] Write unit test: `GroupResolverTest::handles_orphaned_ids` +- Mock Graph response missing some IDs +- Assert orphaned IDs have `displayName: null` +- Assert `orphaned: true` + +**2.10** [X] Write unit test: `GroupResolverTest::caches_results` +- Call resolver twice with same IDs +- Assert Graph API called only once +- Assert cache hit on second call + +**2.11** [X] Create service: `ScopeTagResolver` +- File: `app/Services/Graph/ScopeTagResolver.php` +- Method: `resolve(array $scopeTagIds): array` +- Implement: GET `/deviceManagement/roleScopeTags?$select=id,displayName` +- Return array of scope tag objects +- Cache results (1 hour TTL) + +**2.12** [X] Add cache to `ScopeTagResolver` +- Cache key: `"scope_tags:all"` +- TTL: 1 hour +- Fetch all scope tags once, filter in memory + +**2.13** [X] Write unit test: `ScopeTagResolverTest::resolves_scope_tags` +- Mock Graph response +- Assert correct scope tags returned +- Assert filtered to requested IDs only + +**2.14** [X] Write unit test: `ScopeTagResolverTest::caches_results` +- Call resolver twice +- Assert Graph API called only once +- Assert cache hit on second call + +**2.15** [X] Run Pint: Format service classes +- `./vendor/bin/pint app/Services/Graph/` + +**2.16** [X] Verify service tests pass +- Run: `php artisan test --filter=AssignmentFetcher` +- Run: `php artisan test --filter=GroupResolver` +- Run: `php artisan test --filter=ScopeTagResolver` +- Expected: All green, 90%+ coverage + +**2.17** [X] ⭐ Create service: `AssignmentFilterResolver` +- File: `app/Services/Graph/AssignmentFilterResolver.php` +- GET `/deviceManagement/assignmentFilters?$select=id,displayName` +- Cache results (TTL 1 hour) + +**2.18** [X] ⭐ Write unit test: `AssignmentFilterResolverTest` +- Assert filter name resolution by ID +- Assert cache behavior + +--- + +## Phase 3: US1 - Capture Assignments & Scope Tags (MVP Core) + +**Duration**: 4-5 hours +**Dependencies**: Phase 2 complete +**Parallelizable**: Partially (service and UI separate) + +### Tasks +**3.1** [X] ⭐ Add include checkboxes to "Add policies" action +- File: `app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php` +- Components: `include_assignments`, `include_scope_tags` (default: true) +- Help text: assignments include/exclude + filters, scope tag IDs + +**3.2** [X] ⭐ Add include checkboxes to "Capture snapshot" action +- File: `app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php` +- Components: `include_assignments`, `include_scope_tags` (default: true) + +**3.3** [X] ⭐ Create `PolicyCaptureOrchestrator` +- File: `app/Services/Intune/PolicyCaptureOrchestrator.php` +- Capture payload + assignments + scope tags +- Reuse existing PolicyVersion by snapshot hash +- Backfill assignments/scope tags when missing + +**3.4** [X] ⭐ Store assignments/scope tags on PolicyVersion +- Update `VersionService::captureVersion` to accept assignments/scopeTags +- Store `assignments_hash` and `scope_tags_hash` + +**3.5** [X] ⭐ Link BackupItem to PolicyVersion and copy assignments +- Add `policy_version_id` to backup_items +- BackupService uses orchestrator and copies assignments +- Scope tags live on PolicyVersion only + +**3.6** [X] Update BackupSet items table columns +- Eager-load `policyVersion` +- Assignment count derived from `backup_items.assignments` +- Scope tags read from `policyVersion.scope_tags` + +**3.7** [X] Add audit log entry: `backup.assignments.included` +- Log when `include_assignments` is enabled + +**3.8** [X] ⭐ Write/Update feature tests +- `BackupWithAssignmentsConsistencyTest` +- `VersionCaptureWithAssignmentsTest` +- `PolicyVersionViewAssignmentsTest` + +--- + +## Phase 4: US2 - Policy Version View with Assignments + +**Duration**: 3-4 hours +**Dependencies**: Phase 3 complete +**Parallelizable**: Yes (independent of Phase 5) + +### Tasks + +**4.1** [X] ⭐ Create assignments widget for PolicyVersion view +- Livewire component: `PolicyVersionAssignmentsWidget` +- Render via `ViewPolicyVersion::getFooterWidgets` + +**4.2** [X] Show include/exclude groups and filters +- Map target types to Include/Exclude labels +- Render group display name or "Unknown Group (ID: ...)" +- Show assignment filter name + filter mode + +**4.3** [X] Show scope tags section +- Use `PolicyVersion.scope_tags['names']` +- Empty state when not captured + +**4.4** [X] Remove assignments UI from Policy view +- Policy view no longer shows assignments (moved to PolicyVersion view) + +**4.5** [X] ⭐ Update tests +- `PolicyVersionViewAssignmentsTest` + +--- + +## Phase 5: US3 - Restore with Group Mapping (Core Restore) + +**Duration**: 6-8 hours +**Dependencies**: Phase 4 complete +**Parallelizable**: No (complex, sequential) + +### Tasks + +**5.1** Detect unresolved groups in restore preview +- File: `app/Filament/Resources/RestoreResource/Pages/RestorePreview.php` +- Extract group IDs from source assignments +- Call `POST /directoryObjects/getByIds` for target tenant +- Compare: missing IDs = unresolved + +**5.2** Create Filament wizard component: `GroupMappingStep` +- File: `app/Filament/Forms/Components/GroupMappingStep.php` +- Add as step 2 in restore wizard (between Preview and Confirm) +- Show only if unresolved groups exist + +**5.3** Build group mapping table in wizard step +- Use Filament `Repeater` or custom table +- Columns: Source Group (name or ID), Target Group (dropdown), Skip (checkbox) +- Populate source groups from backup metadata + +**5.4** Implement target group dropdown +- Use `Select::make('target_group_id')` +- `->searchable()` +- `->getSearchResultsUsing()` to query target tenant groups +- `->debounce(500)` for responsive search +- `->lazy()` to load options on demand + +**5.5** Add "Skip" checkbox functionality +- When checked, set mapping value to `"SKIP"` +- Disable target group dropdown when skip checked + +**5.6** Cache target tenant groups +- In wizard `mount()`, call `GroupResolver->getAllForTenant($targetTenantId)` +- Cache for 5 minutes +- Pre-warm dropdown options + +**5.7** Persist group mapping to `RestoreRun` +- On wizard step 2 submit, save mapping to `$restoreRun->group_mapping` +- Use `RestoreRun->addGroupMapping()` helper +- Validate: all unresolved groups either mapped or skipped + +**5.8** Create service: `AssignmentRestoreService` +- File: `app/Services/AssignmentRestoreService.php` +- Method: `restore(string $policyId, array $assignments, array $groupMapping): array` +- Prefer `/assign` action when supported; fallback to DELETE-then-CREATE pattern + +**5.9** Implement DELETE existing assignments (fallback) +- Step 1: GET `/assignments` for target policy +- Step 2: Loop and DELETE each assignment +- Handle 204 No Content (success) +- Log warnings on failure, continue + +**5.10** Implement CREATE new assignments with mapping (fallback) +- Step 3: Loop through source assignments +- Apply group mapping: replace source group IDs with target IDs +- Skip assignments marked `"SKIP"` in mapping +- POST each assignment to `/assignments` +- Handle 201 Created (success) +- Log per-assignment outcome + +**5.11** Add rate limit protection (fallback only) +- Add 100ms delay between sequential POST calls: `usleep(100000)` +- Log request IDs for failed calls + +**5.12** Handle per-assignment failures gracefully +- Don't throw on failure, collect outcomes +- Outcomes array: `['status' => 'success|failed|skipped', 'assignment' => ..., 'error' => ...]` +- Continue with remaining assignments (fail-soft) + +**5.13** Store outcomes in `RestoreRun->results` +- Add `assignment_outcomes` key to results JSON +- Include successful, failed, skipped counts +- Store error details for failed assignments + +**5.14** Add audit log entries +- `restore.group_mapping.applied`: When mapping saved +- `restore.assignment.created`: Per successful assignment +- `restore.assignment.failed`: Per failed assignment +- `restore.assignment.skipped`: Per skipped assignment + +**5.15** Create job: `RestoreAssignmentsJob` +- File: `app/Jobs/RestoreAssignmentsJob.php` +- Dispatch async after restore initiated +- Call `AssignmentRestoreService` +- Handle job failures (log, mark RestoreRun as failed) + +**5.16** Write feature test: `RestoreGroupMappingTest::detects_unresolved_groups` +- Create backup with group IDs +- Target tenant has different groups +- Start restore wizard +- Assert group mapping step appears +- Assert unresolved groups listed + +**5.17** Write feature test: `RestoreGroupMappingTest::persists_group_mapping` +- Fill out group mapping form +- Submit wizard +- Assert `RestoreRun->group_mapping` populated +- Assert audit log entry created + +**5.18** Write feature test: `RestoreGroupMappingTest::skips_mapped_groups` +- Map some groups, skip others +- Complete restore +- Assert skipped groups have `"SKIP"` value +- Assert skipped assignments not created + +**5.19** Write feature test: `RestoreAssignmentApplicationTest::applies_assignments_successfully` +- Mock Graph API (DELETE 204, POST 201) +- Restore with mapped groups +- Assert assignments created in target tenant +- Assert outcomes logged + +**5.20** Write feature test: `RestoreAssignmentApplicationTest::handles_failures_gracefully` +- Mock Graph API: some POSTs fail (400 or 500) +- Restore with mapped groups +- Assert successful assignments still created +- Assert failed assignments logged with errors +- Assert RestoreRun status reflects partial success + +**5.21** Run Pint: Format restore code +- `./vendor/bin/pint app/Services/AssignmentRestoreService.php` +- `./vendor/bin/pint app/Jobs/RestoreAssignmentsJob.php` +- `./vendor/bin/pint app/Filament/Forms/Components/GroupMappingStep.php` + +**5.22** Verify feature tests pass +- Run: `php artisan test --filter=RestoreGroupMapping` +- Run: `php artisan test --filter=RestoreAssignmentApplication` +- Expected: All green + +--- + +## Phase 6: US4 - Restore Preview with Assignment Diff + +**Duration**: 2-3 hours +**Dependencies**: Phase 5 complete +**Parallelizable**: Yes (extends Phase 5, doesn't block) + +### Tasks + +**6.1** Fetch target policy's current assignments +- In restore preview, call `AssignmentFetcher` for target policy +- Cache for duration of wizard session + +**6.2** Implement diff algorithm +- Compare source assignments with target assignments +- Group by change type: + - **Added**: In source, not in target + - **Removed**: In target, not in source + - **Unchanged**: In both (match by group ID + intent) + +**6.3** Display diff in Restore Preview +- Use Filament `Infolist` with color coding: + - Green (success): Added assignments + - Red (danger): Removed assignments + - Gray (secondary): Unchanged assignments +- Show counts: "3 added, 1 removed, 2 unchanged" + +**6.4** Add Scope Tag diff +- Compare source scope tag IDs with target tenant scope tags +- Display: "Scope Tags: 2 matched, 1 not found in target" +- Show warning icon for missing scope tags + +**6.5** Update feature test: `RestoreAssignmentApplicationTest::displays_assignment_diff` +- Visit restore preview +- Assert diff sections visible +- Assert color coding correct +- Assert counts accurate + +**6.6** Run Pint: Format diff code +- `./vendor/bin/pint app/Filament/Resources/RestoreResource/Pages/RestorePreview.php` + +**6.7** Verify test passes +- Run: `php artisan test --filter=displays_assignment_diff` +- Expected: Green + +--- + +## Phase 7: Scope Tags (Full Support) + +**Duration**: 2-3 hours +**Dependencies**: Phase 6 complete +**Parallelizable**: Yes (extends existing code) + +### Tasks + +**7.1** Extract scope tag IDs during backup +- In `AssignmentBackupService`, read `roleScopeTagIds` from policy payload +- Store in `metadata['scope_tag_ids']` + +**7.2** Resolve scope tag names during backup +- Call `ScopeTagResolver->resolve($scopeTagIds)` +- Store in `metadata['scope_tag_names']` + +**7.3** Validate scope tags during restore +- In `AssignmentRestoreService`, check if scope tag IDs exist in target tenant +- Call `POST /directoryObjects/getByIds` with type `['scopeTag']` (if supported) or GET all scope tags + +**7.4** Log warnings for missing scope tags +- Log: "Scope Tag '{id}' not found in target tenant, restore will proceed" +- Don't block restore (Graph API handles missing scope tags gracefully) + +**7.5** Update unit test: `ScopeTagResolverTest::handles_missing_scope_tags` +- Mock Graph response with some scope tags missing +- Assert warnings logged +- Assert restore proceeds + +**7.6** Update feature test: `RestoreAssignmentApplicationTest::handles_missing_scope_tags` +- Restore with scope tag IDs not in target tenant +- Assert warnings logged +- Assert policy created successfully + +**7.7** Run Pint: Format scope tag code + +**7.8** Verify tests pass +- Run: `php artisan test --filter=ScopeTag` +- Expected: Green + +--- + +## Phase 8: Polish & Performance + +**Duration**: 3-4 hours +**Dependencies**: Phase 7 complete +**Parallelizable**: Partially (UI polish vs performance tuning) + +### Tasks + +**8.1** Add loading indicators to group mapping dropdown +- Use `wire:loading` on Select component +- Show spinner during search +- Disable dropdown while loading + +**8.2** Add debouncing to group search +- Set `->debounce(500)` on Select component +- Test: Type quickly, verify search only fires after 500ms pause + +**8.3** Optimize Graph API calls: batch group resolution +- In `GroupResolver`, batch max 100 IDs per POST +- If > 100 groups, split into chunks: `collect($groupIds)->chunk(100)` +- Merge results + +**8.4** Add cache warming for target tenant groups +- In wizard `mount()`, pre-fetch all target tenant groups +- Cache for 5 minutes +- Display loading message while warming + +**8.5** Add tooltips to UI elements +- Include assignments checkbox: "Captures include/exclude targeting and filters." +- Include scope tags checkbox: "Captures policy scope tag IDs." +- Group mapping: "Map source groups to target groups for cross-tenant migrations." +- Skip checkbox: "Don't restore assignments targeting this group." + +**8.6** Update README: Add "Assignments & Scope Tags" section +- Overview of feature +- How to use include assignments/scope tags (Add Policies + Capture snapshot) +- How to use group mapping wizard +- Troubleshooting tips + +**8.7** Create performance test: Large restore +- Restore 50 policies with 10 assignments each +- Measure duration +- Target: < 5 minutes +- Log: Graph API call count, cache hits + +**8.8** Run performance test +- Execute test +- Record results in `quickstart.md` +- Optimize if needed (e.g., increase cache TTL) + +**8.9** Run Pint: Format all code + +**8.10** Final test run +- Run: `php artisan test` +- Expected: All tests green, 85%+ coverage + +--- + +## Phase 9: Testing & QA + +**Duration**: 2-3 hours +**Dependencies**: Phase 8 complete +**Parallelizable**: No (QA sequential) + +### Tasks + +**9.1** Manual QA: Add policies with assignments (same tenant) +- Open a Backup Set and add policies +- Enable "Include assignments" (and scope tags if desired) +- Verify add completes +- Verify assignments stored in DB +- Verify audit log entry + +**9.2** Manual QA: Add policies without assignments +- Add policies to a Backup Set or capture a snapshot +- Disable "Include assignments" +- Verify add/capture completes +- Verify assignments column is null + +**9.3** Manual QA: Policy Version view with assignments widget +- Navigate to a Policy Version with assignments +- Verify widget renders +- Verify orphaned IDs show warning +- Verify scope tags section + +**9.4** Manual QA: Restore to same tenant (auto-match) +- Restore backup to original tenant +- Verify no group mapping step appears +- Verify assignments restored correctly + +**9.5** Manual QA: Restore to different tenant (group mapping) +- Restore backup to different tenant +- Verify group mapping step appears +- Fill out mapping (map some, skip others) +- Verify assignments restored with mapped IDs +- Verify skipped assignments not created + +**9.6** Manual QA: Handle orphaned group IDs +- Create backup with group ID that doesn't exist in target +- Restore to target tenant +- Verify warning displayed +- Verify orphaned group rendered as "Unknown Group" + +**9.7** Manual QA: Handle Graph API failures +- Simulate API failure (disable network or use Http::fake with 500 response) +- Attempt add policies or capture snapshot with "Include assignments" +- Verify fail-soft behavior +- Verify warning logged + +**9.8** Browser test: `GroupMappingWizardTest` +- Use Pest browser testing +- Navigate restore wizard +- Fill out group mapping +- Submit +- Verify mapping persisted + +**9.9** Load testing: 100+ policies +- Create 100 policies with 20 assignments each +- Restore to target tenant +- Measure duration +- Verify < 5 minutes + +**9.10** Staging deployment +- Deploy to staging via Dokploy +- Run manual QA scenarios on staging +- Verify no issues + +**9.11** Document QA results +- Update `quickstart.md` with QA checklist results +- Note any issues or edge cases discovered + +--- + +## Phase 10: Deployment & Documentation + +**Duration**: 1-2 hours +**Dependencies**: Phase 9 complete +**Parallelizable**: No (sequential deployment) + +### Tasks + +**10.1** Review deployment checklist +- Migrations ready? ✅ +- Rollback tested? ✅ +- Env vars needed? (None for this feature) +- Queue workers running? ✅ + +**10.2** Run migrations on staging +- SSH to staging server +- Run: `php artisan migrate` +- Verify no errors +- Check backup_items and restore_runs tables + +**10.3** Smoke test on staging +- Add policies to a Backup Set with "Include assignments" +- Restore to same tenant +- Verify success + +**10.4** Update spec.md with implementation notes +- Add "Implementation Status" section +- Note any deviations from original spec +- Document known limitations (e.g., Settings Catalog only in Phase 1) + +**10.5** Create migration guide +- Document: "Existing backups will NOT have assignments (not retroactive)" +- Document: "Re-add policies or capture snapshots with include assignments/scope tags to capture data" + +**10.6** Add monitoring alerts +- Alert: Assignment fetch failure rate > 10% +- Alert: Group resolution failure rate > 5% +- Use Laravel logs or external monitoring (e.g., Sentry) + +**10.7** Production deployment +- Deploy to production via Dokploy +- Run migrations +- Monitor logs for 24 hours +- Check for errors + +**10.8** Verify no rate limit issues +- Monitor Graph API headers: `X-RateLimit-Remaining` +- If rate limiting detected, increase delays (currently 100ms) + +**10.9** Final documentation update +- Update README with feature status +- Link to quickstart.md for developers +- Add to feature list + +**10.10** Close tasks in tasks.md +- Mark all tasks complete ✅ +- Archive planning docs (optional) + +--- + +## Task Summary + +### MVP Scope (⭐ Tasks) +**Total**: ~24 tasks +**Estimated**: 16-22 hours + +**Breakdown**: +- Phase 1 (Setup): 17 tasks (2-3h) +- Phase 2 (Graph Services): 18 tasks (4-6h) +- Phase 3 (Capture): 8 tasks (4-5h) +- Phase 4 (Policy Version view): 5 tasks (3-4h) +- Phase 5 (Restore, basic): Partial (subset for same-tenant restore) + +**MVP Scope Definition**: +- ✅ Capture assignments/scope tags in Add Policies + Capture Snapshot (US1) +- ✅ Policy Version assignments widget (US2) +- ✅ Basic restore (same tenant, auto-match) (US3 partial) +- ❌ Group mapping wizard (defer to post-MVP) +- ❌ Restore preview diff (defer to post-MVP) +- ⚠️ Scope tags restore (capture done, restore deferred) + +--- + +### Full Implementation +**Total**: ~64 tasks +**Estimated**: 30-40 hours + +**Breakdown**: +- Phase 1: 17 tasks (2-3h) +- Phase 2: 18 tasks (4-6h) +- Phase 3: 8 tasks (4-5h) +- Phase 4: 5 tasks (3-4h) +- Phase 5: 22 tasks (6-8h) +- Phase 6: 7 tasks (2-3h) +- Phase 7: 8 tasks (2-3h) +- Phase 8: 10 tasks (3-4h) +- Phase 9: 11 tasks (2-3h) +- Phase 10: 10 tasks (1-2h) + +--- + +## Parallel Development Opportunities + +### Track 1: Backend Services (Dev A) +- Phase 1: Database setup (17 tasks) +- Phase 2: Graph API services (18 tasks) +- Phase 3: Capture services (8 tasks) +- Phase 5: Restore service (22 tasks) + +**Total**: ~16-22 hours + +--- + +### Track 2: Frontend/UI (Dev B) +- Phase 4: Policy Version view (5 tasks) +- Phase 5: Group mapping wizard (subset of Phase 5, ~10 tasks) +- Phase 6: Restore preview diff (7 tasks) +- Phase 8: UI polish (subset, ~5 tasks) + +**Total**: ~12-16 hours + +--- + +### Track 3: Testing & QA (Dev C or shared) +- Phase 2: Unit tests for services (subset) +- Phase 3: Feature tests for backup (subset) +- Phase 4: Feature tests for policy version view (subset) +- Phase 9: Manual QA + browser tests (11 tasks) + +**Total**: ~8-12 hours + +**Note**: Testing can be done incrementally as phases complete. + +--- + +## Dependencies Graph + +``` +Phase 1 (Setup) + ↓ +Phase 2 (Graph Services) + ↓ +Phase 3 (Capture) → Phase 4 (Policy Version view) + ↓ ↓ +Phase 5 (Restore) ←------+ + ↓ +Phase 6 (Preview Diff) + ↓ +Phase 7 (Scope Tags) + ↓ +Phase 8 (Polish) + ↓ +Phase 9 (Testing & QA) + ↓ +Phase 10 (Deployment) +``` + +**Parallel Phases**: Phase 3 and Phase 4 can run in parallel after Phase 2. + +--- + +## Risk Mitigation Tasks + +### Risk: Graph API Rate Limiting +- **Task 2.11**: Add retry logic with exponential backoff +- **Task 5.11**: Add 100ms delay between POSTs +- **Task 8.3**: Batch group resolution (max 100 per request) + +### Risk: Large Group Counts (500+) +- **Task 5.4**: Implement searchable dropdown with debounce +- **Task 5.6**: Cache target tenant groups +- **Task 8.4**: Pre-warm cache on wizard mount + +### Risk: Assignment Restore Failures +- **Task 5.12**: Fail-soft per-assignment error handling +- **Task 5.13**: Store outcomes in RestoreRun results +- **Task 5.14**: Audit log all outcomes + +--- + +## Next Actions + +1. **Review**: Team review of tasks.md, adjust estimates if needed +2. **Assign**: Assign tasks to developers (Track 1/2/3) +3. **Start**: Begin with Phase 1 (Setup & Database) +4. **Track Progress**: Update task status as work progresses +5. **Iterate**: Adjust plan based on discoveries during implementation + +--- + +**Status**: Task Breakdown Complete +**Ready for Implementation**: ✅ +**Estimated Duration**: 30-40 hours (MVP: 16-22 hours) diff --git a/specs/005-bulk-operations/data-model.md b/specs/005-bulk-operations/data-model.md new file mode 100644 index 0000000..47003c1 --- /dev/null +++ b/specs/005-bulk-operations/data-model.md @@ -0,0 +1,711 @@ +# Data Model: Feature 005 - Bulk Operations + +**Feature**: Bulk Operations for Resource Management +**Date**: 2025-12-22 + +--- + +## Overview + +This document describes the data model for bulk operations, including new entities, schema changes, relationships, and query patterns. + +--- + +## Entity Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ BulkOperationRun │ +├─────────────────────────────────────────────────────────────┤ +│ id: bigint PK │ +│ tenant_id: bigint FK → Tenant │ +│ user_id: bigint FK → User │ +│ resource: string (policies, policy_versions, etc.) │ +│ action: string (delete, export, prune, etc.) │ +│ status: enum (pending, running, completed, failed, aborted) │ +│ total_items: int │ +│ processed_items: int │ +│ succeeded: int │ +│ failed: int │ +│ skipped: int │ +│ item_ids: jsonb (array of IDs) │ +│ failures: jsonb (array of {id, reason}) │ +│ audit_log_id: bigint FK → AuditLog (nullable) │ +│ created_at: timestamp │ +│ updated_at: timestamp │ +└─────────────────────────────────────────────────────────────┘ + │ │ │ + │ │ └─────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Tenant │ │ User │ │ AuditLog │ +└──────────────┘ └──────────────┘ └──────────────┘ + + +┌─────────────────────────────────────────┐ +│ Policy (Extended) │ +├─────────────────────────────────────────┤ +│ id: bigint PK │ +│ tenant_id: bigint FK │ +│ graph_id: string │ +│ name: string │ +│ ... (existing columns) │ +│ deleted_at: timestamp (nullable) │ +│ ignored_at: timestamp (nullable) ← NEW │ +└─────────────────────────────────────────┘ + + +┌─────────────────────────────────────────┐ +│ PolicyVersion (Extended) │ +├─────────────────────────────────────────┤ +│ id: bigint PK │ +│ policy_id: bigint FK → Policy │ +│ is_current: boolean │ +│ created_at: timestamp │ +│ ... (existing columns) │ +├─────────────────────────────────────────┤ +│ Scope: pruneEligible() ← NEW │ +│ WHERE is_current = false │ +│ AND created_at < NOW() - 90 days │ +│ AND NOT IN (backup_items) │ +│ AND NOT IN (restore_runs.metadata) │ +└─────────────────────────────────────────┘ + + +┌─────────────────────────────────────────┐ +│ RestoreRun (Extended) │ +├─────────────────────────────────────────┤ +│ id: bigint PK │ +│ status: enum (pending, running, ...) │ +│ ... (existing columns) │ +├─────────────────────────────────────────┤ +│ Scope: deletable() ← NEW │ +│ WHERE status IN (completed, failed) │ +└─────────────────────────────────────────┘ +``` + +--- + +## Database Schema + +### New Table: bulk_operation_runs + +```sql +CREATE TABLE bulk_operation_runs ( + id BIGSERIAL PRIMARY KEY, + tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + resource VARCHAR(50) NOT NULL, -- 'policies', 'policy_versions', 'backup_sets', 'restore_runs' + action VARCHAR(50) NOT NULL, -- 'delete', 'export', 'prune', 'archive' + status VARCHAR(20) NOT NULL, -- 'pending', 'running', 'completed', 'failed', 'aborted' + total_items INT NOT NULL, + processed_items INT NOT NULL DEFAULT 0, + succeeded INT NOT NULL DEFAULT 0, + failed INT NOT NULL DEFAULT 0, + skipped INT NOT NULL DEFAULT 0, + item_ids JSONB NOT NULL, -- [1, 2, 3, ...] + failures JSONB, -- [{"id": 1, "reason": "Graph error"}, ...] + audit_log_id BIGINT REFERENCES audit_logs(id) ON DELETE SET NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_bulk_operation_runs_tenant_resource_status + ON bulk_operation_runs(tenant_id, resource, status); + +CREATE INDEX idx_bulk_operation_runs_user_created + ON bulk_operation_runs(user_id, created_at DESC); + +CREATE INDEX idx_bulk_operation_runs_status + ON bulk_operation_runs(status) + WHERE status IN ('pending', 'running'); +``` + +**Indexes Rationale**: +- Composite (tenant_id, resource, status): Filter by tenant + resource type + status (UI queries) +- (user_id, created_at): User's recent bulk operations +- Partial index on status: Only index running/pending (99% of queries check these) + +--- + +### Schema Change: policies table + +```sql +ALTER TABLE policies +ADD COLUMN ignored_at TIMESTAMP NULL; + +CREATE INDEX idx_policies_ignored_at + ON policies(ignored_at) + WHERE ignored_at IS NOT NULL; +``` + +**Purpose**: Prevent SyncPoliciesJob from re-importing locally deleted policies. + +**Query Pattern**: +```sql +-- Sync job filters +SELECT * FROM policies WHERE ignored_at IS NULL; + +-- Bulk delete sets +UPDATE policies SET ignored_at = NOW() WHERE id IN (...); +``` + +--- + +### Schema Change: policy_versions table + +**No schema changes needed.** Eligibility scope uses existing columns: +- `is_current` (boolean) +- `created_at` (timestamp) +- Foreign keys checked via relationships + +--- + +## Eloquent Models + +### New Model: BulkOperationRun + +```php + 'array', + 'failures' => 'array', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // Relationships + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function auditLog(): BelongsTo + { + return $this->belongsTo(AuditLog::class); + } + + // Status Helpers + + public function isRunning(): bool + { + return $this->status === 'running'; + } + + public function isComplete(): bool + { + return in_array($this->status, ['completed', 'failed', 'aborted']); + } + + public function isPending(): bool + { + return $this->status === 'pending'; + } + + // Progress Helpers + + public function progressPercentage(): int + { + if ($this->total_items === 0) { + return 0; + } + return (int) round(($this->processed_items / $this->total_items) * 100); + } + + public function summaryText(): string + { + return match ($this->status) { + 'pending' => "Pending: {$this->total_items} items", + 'running' => "Processing... {$this->processed_items}/{$this->total_items}", + 'completed' => "Completed: {$this->succeeded} succeeded, {$this->failed} failed, {$this->skipped} skipped", + 'failed' => "Failed: {$this->failed}/{$this->total_items} errors", + 'aborted' => "Aborted: Too many failures ({$this->failed}/{$this->total_items})", + default => "Unknown status", + }; + } + + public function hasFailures(): bool + { + return $this->failed > 0; + } + + // Scopes + + public function scopeForResource($query, string $resource) + { + return $query->where('resource', $resource); + } + + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeRecent($query) + { + return $query->orderBy('created_at', 'desc'); + } + + public function scopeInProgress($query) + { + return $query->whereIn('status', ['pending', 'running']); + } +} +``` + +--- + +### Extended Model: Policy + +```php +whereNull('ignored_at'); + } + + public function scopeIgnored($query) + { + return $query->whereNotNull('ignored_at'); + } + + // NEW: Methods for bulk operations + + public function markIgnored(): void + { + $this->update(['ignored_at' => now()]); + } + + public function unmarkIgnored(): void + { + $this->update(['ignored_at' => null]); + } + + public function isIgnored(): bool + { + return $this->ignored_at !== null; + } +} +``` + +--- + +### Extended Model: PolicyVersion + +```php +hasMany(BackupItem::class, 'policy_version_id'); + } + + // NEW: Scope for eligibility check + + public function scopePruneEligible($query, int $retentionDays = 90) + { + return $query + ->where('is_current', false) + ->where('created_at', '<', now()->subDays($retentionDays)) + ->whereDoesntHave('backupItems') + ->whereNotIn('id', function ($subquery) { + $subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)")) + ->from('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")); + }); + } + + // NEW: Check if version is eligible for pruning + + public function isPruneEligible(int $retentionDays = 90): bool + { + if ($this->is_current) { + return false; + } + + if ($this->created_at->diffInDays(now()) < $retentionDays) { + return false; + } + + if ($this->backupItems()->exists()) { + return false; + } + + $referencedInRestoreRuns = DB::table('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")) + ->where(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"), $this->id) + ->exists(); + + return !$referencedInRestoreRuns; + } + + // NEW: Get reason why version is not eligible + + public function getIneligibilityReason(int $retentionDays = 90): ?string + { + if ($this->is_current) { + return 'Current version'; + } + + if ($this->created_at->diffInDays(now()) < $retentionDays) { + return "Too recent (< {$retentionDays} days)"; + } + + if ($this->backupItems()->exists()) { + return 'Referenced in backup items'; + } + + $referencedInRestoreRuns = DB::table('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")) + ->where(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)"), $this->id) + ->exists(); + + if ($referencedInRestoreRuns) { + return 'Referenced in restore runs'; + } + + return null; // Eligible + } +} +``` + +--- + +### Extended Model: RestoreRun + +```php +whereIn('status', ['completed', 'failed', 'aborted']); + } + + public function scopeNotDeletable($query) + { + return $query->whereNotIn('status', ['completed', 'failed', 'aborted']); + } + + // NEW: Check if restore run can be deleted + + public function isDeletable(): bool + { + return in_array($this->status, ['completed', 'failed', 'aborted']); + } + + public function getNotDeletableReason(): ?string + { + if ($this->isDeletable()) { + return null; + } + + return match ($this->status) { + 'pending' => 'Restore run is pending', + 'running' => 'Restore run is currently running', + default => "Restore run has status: {$this->status}", + }; + } +} +``` + +--- + +## Query Patterns + +### 1. Create Bulk Operation Run + +```php +$run = BulkOperationRun::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'resource' => 'policies', + 'action' => 'delete', + 'status' => 'pending', + 'total_items' => count($policyIds), + 'item_ids' => $policyIds, +]); + +// Dispatch job +BulkPolicyDeleteJob::dispatch($policyIds, $tenantId, $userId, $run->id); + +$run->update(['status' => 'running']); +``` + +### 2. Update Progress (in job) + +```php +$run->update([ + 'processed_items' => $run->processed_items + $chunkSize, + 'succeeded' => $run->succeeded + $successCount, + 'failed' => $run->failed + $failCount, + 'failures' => array_merge($run->failures ?? [], $newFailures), +]); +``` + +### 3. Query Recent Bulk Operations (UI) + +```php +// User's recent operations +$runs = BulkOperationRun::forUser(auth()->id()) + ->recent() + ->limit(10) + ->get(); + +// Tenant's in-progress operations +$inProgress = BulkOperationRun::where('tenant_id', $tenantId) + ->inProgress() + ->get(); +``` + +### 4. Filter Policies (exclude ignored) + +```php +// Sync job +$policies = Policy::where('tenant_id', $tenantId) + ->notIgnored() + ->get(); + +// Bulk delete (mark as ignored) +Policy::whereIn('id', $policyIds) + ->update(['ignored_at' => now()]); +``` + +### 5. Check Policy Version Eligibility + +```php +// Get all eligible versions for tenant +$eligibleVersions = PolicyVersion::whereHas('policy', function ($query) use ($tenantId) { + $query->where('tenant_id', $tenantId); + }) + ->pruneEligible(90) + ->get(); + +// Check single version +$version = PolicyVersion::find($id); +if (!$version->isPruneEligible()) { + $reason = $version->getIneligibilityReason(); + // Skip with reason: "Referenced in backup items" +} +``` + +### 6. Filter Deletable Restore Runs + +```php +// Get deletable runs +$deletableRuns = RestoreRun::where('tenant_id', $tenantId) + ->deletable() + ->get(); + +// Check individual run +$run = RestoreRun::find($id); +if (!$run->isDeletable()) { + $reason = $run->getNotDeletableReason(); + // Skip: "Restore run is currently running" +} +``` + +--- + +## JSONB Structure + +### bulk_operation_runs.item_ids + +```json +[1, 2, 3, 4, 5, ...] +``` + +Simple array of integer IDs. + +### bulk_operation_runs.failures + +```json +[ + { + "id": 123, + "reason": "Graph API error: 503 Service Unavailable" + }, + { + "id": 456, + "reason": "Policy not found" + }, + { + "id": 789, + "reason": "Permission denied" + } +] +``` + +Array of objects with `id` (resource ID) and `reason` (error message). + +### restore_runs.metadata (existing) + +```json +{ + "policy_version_id": 42, + "backup_set_id": 15, + "items_count": 10, + ... +} +``` + +When checking eligibility, query: +```sql +SELECT * FROM restore_runs +WHERE metadata->>'policy_version_id' = '42'; +``` + +--- + +## Migration Files + +### Migration 1: Create bulk_operation_runs table + +```php +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('resource', 50); + $table->string('action', 50); + $table->string('status', 20); + $table->integer('total_items'); + $table->integer('processed_items')->default(0); + $table->integer('succeeded')->default(0); + $table->integer('failed')->default(0); + $table->integer('skipped')->default(0); + $table->json('item_ids'); + $table->json('failures')->nullable(); + $table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete(); + $table->timestamps(); + + $table->index(['tenant_id', 'resource', 'status']); + $table->index(['user_id', 'created_at']); + $table->index('status')->where('status', 'in', ['pending', 'running']); + }); + } + + public function down(): void + { + Schema::dropIfExists('bulk_operation_runs'); + } +}; +``` + +### Migration 2: Add ignored_at to policies + +```php +timestamp('ignored_at')->nullable()->after('deleted_at'); + $table->index('ignored_at'); + }); + } + + public function down(): void + { + Schema::table('policies', function (Blueprint $table) { + $table->dropIndex(['ignored_at']); + $table->dropColumn('ignored_at'); + }); + } +}; +``` + +--- + +## Data Retention & Cleanup + +### BulkOperationRun Retention + +Recommended: Keep for 90 days, then archive or delete completed runs. + +```php +// Scheduled command +Artisan::command('bulk-operations:prune', function () { + $deleted = BulkOperationRun::where('created_at', '<', now()->subDays(90)) + ->whereIn('status', ['completed', 'failed', 'aborted']) + ->delete(); + + $this->info("Pruned {$deleted} old bulk operation runs"); +})->daily(); +``` + +### PolicyVersion Retention + +Handled by bulk prune feature (user-initiated, not automatic). + +--- + +**Status**: Data Model Complete +**Next Step**: Generate quickstart.md diff --git a/specs/005-bulk-operations/plan.md b/specs/005-bulk-operations/plan.md new file mode 100644 index 0000000..0a79cf8 --- /dev/null +++ b/specs/005-bulk-operations/plan.md @@ -0,0 +1,263 @@ +# Implementation Plan: Feature 005 - Bulk Operations + +**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-22 | **Spec**: [spec.md](./spec.md) + +## Summary + +Enable efficient bulk operations (delete, export, prune) across TenantPilot's main resources (Policies, Policy Versions, Backup Sets, Restore Runs) with safety gates, progress tracking, and comprehensive audit logging. Technical approach: Filament bulk actions + Laravel Queue jobs with chunked processing + BulkOperationRun tracking model + Livewire polling for progress updates. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Framework**: Laravel 12 +**Primary Dependencies**: +- Filament v4 (admin panel + bulk actions) +- Livewire v3 (reactive UI + polling) +- Laravel Queue (async job processing) +- PostgreSQL (JSONB for tracking) + +**Storage**: PostgreSQL with JSONB fields for: +- `bulk_operation_runs.item_ids` (array of resource IDs) +- `bulk_operation_runs.failures` (per-item error details) +- Existing audit logs (metadata column) + +**Testing**: Pest v4 (unit, feature, browser tests) +**Target Platform**: Web (Dokploy deployment) +**Project Type**: Web application (Filament admin panel) + +**Performance Goals**: +- Process 100 items in <2 minutes (queued) +- Handle up to 500 items per operation without timeout +- Progress notifications update every 5-10 seconds + +**Constraints**: +- Queue jobs MUST process in chunks of 10-20 items (memory efficiency) +- Progress tracking requires explicit polling (not automatic in Filament) +- Type-to-confirm required for ≥20 destructive items +- Tenant isolation enforced at job level + +**Scale/Scope**: +- 4 primary resources (Policies, PolicyVersions, BackupSets, RestoreRuns) +- 8-12 bulk actions (P1/P2 priority) +- Estimated 26-34 hours implementation (3 phases for P1/P2) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +**Note**: Project constitution is template-only (not populated). Using Laravel/TenantPilot conventions instead. + +### Architecture Principles + +✅ **Library-First**: N/A (feature extends existing app, no new libraries) +✅ **Test-First**: TDD enforced - Pest tests required before implementation +✅ **Simplicity**: Uses existing patterns (Jobs, Filament bulk actions, Livewire polling) +✅ **Sail-First**: Local development uses Laravel Sail (Docker) +✅ **Dokploy Deployment**: Production/staging via Dokploy (VPS containers) + +### Laravel Conventions + +✅ **PSR-12**: Code formatting enforced via Laravel Pint +✅ **Eloquent-First**: No raw DB queries, use Model::query() patterns +✅ **Permission Gates**: Leverage existing RBAC (Feature 001) +✅ **Queue Jobs**: Use ShouldQueue interface, chunked processing +✅ **Audit Logging**: Extend existing AuditLog model/service + +### Safety Requirements + +✅ **Tenant Isolation**: Job constructor accepts explicit `tenantId` +✅ **Audit Trail**: One audit log entry per bulk operation + per-item outcomes +✅ **Confirmation**: Type-to-confirm for ≥20 destructive items +✅ **Fail-Soft**: Continue processing on individual failures, abort if >50% fail +✅ **Immutability**: Policy Versions check eligibility before prune (referenced, current, age) + +### Gates + +🔒 **GATE-01**: Bulk operations MUST use existing permission model (policies.delete, etc.) +🔒 **GATE-02**: Progress tracking MUST use BulkOperationRun model (not fire-and-forget) +🔒 **GATE-03**: Type-to-confirm MUST be case-sensitive "DELETE" for ≥20 items +🔒 **GATE-04**: Policies bulk delete = local only (ignored_at flag, NO Graph DELETE) + +## Project Structure + +### Documentation (this feature) + +```text +specs/005-bulk-operations/ +├── plan.md # This file +├── research.md # Phase 0 output (see below) +├── data-model.md # Phase 1 output (see below) +├── quickstart.md # Phase 1 output (see below) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT YET CREATED) +``` + +### Source Code (repository root) + +```text +app/ +├── Models/ +│ ├── BulkOperationRun.php # NEW: Tracks progress/outcomes +│ ├── Policy.php # EXTEND: Add markIgnored() scope +│ ├── PolicyVersion.php # EXTEND: Add pruneEligible() scope +│ ├── BackupSet.php # EXTEND: Cascade delete logic +│ └── RestoreRun.php # EXTEND: Skip running status +│ +├── Jobs/ +│ ├── BulkPolicyDeleteJob.php # NEW: Async bulk delete (local) +│ ├── BulkPolicyExportJob.php # NEW: Export to backup set +│ ├── BulkPolicyVersionPruneJob.php # NEW: Prune old versions +│ ├── BulkBackupSetDeleteJob.php # NEW: Delete backup sets +│ └── BulkRestoreRunDeleteJob.php # NEW: Delete restore runs +│ +├── Services/ +│ ├── BulkOperationService.php # NEW: Orchestrates bulk ops + tracking +│ └── Audit/ +│ └── AuditLogger.php # EXTEND: Add bulk operation events +│ +├── Filament/ +│ └── Resources/ +│ ├── PolicyResource.php # EXTEND: Add bulk actions +│ ├── PolicyVersionResource.php # EXTEND: Add bulk prune +│ ├── BackupSetResource.php # EXTEND: Add bulk delete +│ └── RestoreRunResource.php # EXTEND: Add bulk delete +│ +└── Livewire/ + └── BulkOperationProgress.php # NEW: Progress polling component + +database/ +└── migrations/ + └── YYYY_MM_DD_create_bulk_operation_runs_table.php # NEW + +tests/ +├── Unit/ +│ ├── BulkPolicyDeleteJobTest.php +│ ├── BulkActionPermissionTest.php +│ └── BulkEligibilityCheckTest.php +│ +└── Feature/ + ├── BulkDeletePoliciesTest.php + ├── BulkExportToBackupTest.php + ├── BulkProgressNotificationTest.php + └── BulkTypeToConfirmTest.php +``` + +**Structure Decision**: Single web application structure (Laravel + Filament). New bulk operations extend existing Resources with BulkAction definitions. New BulkOperationRun model tracks async job progress. No separate API layer needed (Livewire polling uses Filament infolists/resource pages). + +## Complexity Tracking + +> No constitution violations requiring justification. + +--- + +## Phase 0: Research & Technology Decisions + +See [research.md](./research.md) for detailed research findings. + +### Key Decisions Summary + +| Decision | Chosen | Rationale | +|----------|--------|-----------| +| Progress tracking | BulkOperationRun model + Livewire polling | Explicit state, survives page refresh, queryable outcomes | +| Job chunking | collect()->chunk(10) | Simple, memory-efficient, easy to test | +| Type-to-confirm | Filament form + validation rule | Built-in UI, reusable pattern | +| Tenant isolation | Explicit tenantId param | Fail-safe, auditable, no reliance on global scopes | +| Policy deletion | ignored_at flag | Prevents re-sync, restorable, doesn't touch Intune | +| Eligibility checks | Eloquent scopes | Reusable, testable, composable | + +--- + +## Phase 1: Data Model & Contracts + +See [data-model.md](./data-model.md) for detailed schemas and entity diagrams. + +### Core Entities + +**BulkOperationRun** (NEW): +- Tracks progress, outcomes, failures for bulk operations +- Fields: resource, action, status, total_items, processed_items, succeeded, failed, skipped +- JSONB: item_ids, failures +- Relationships: tenant, user, auditLog + +**Policy** (EXTEND): +- Add `ignored_at` timestamp (prevents re-sync) +- Add `markIgnored()` method and `notIgnored()` scope + +**PolicyVersion** (EXTEND): +- Add `pruneEligible()` scope (checks age, references, current status) + +**RestoreRun** (EXTEND): +- Add `deletable()` scope (filters by completed/failed status) + +--- + +## Phase 2: Implementation Tasks + +Detailed tasks will be generated via `/speckit.tasks` command. High-level phases: + +### Phase 2.1: Foundation (P1 - Policies) - 8-12 hours +- BulkOperationRun migration + model +- Policies: ignored_at column, bulk delete/export jobs +- Filament bulk actions + type-to-confirm +- BulkOperationService orchestration +- Tests (unit, feature) + +### Phase 2.2: Progress Tracking (P1) - 8-10 hours +- Livewire progress component +- Job progress updates (chunked) +- Circuit breaker (>50% fail abort) +- Audit logging integration +- Tests (progress, polling, audit) + +### Phase 2.3: Additional Resources (P2) - 6-8 hours +- PolicyVersion prune (eligibility scope) +- BackupSet bulk delete +- RestoreRun bulk delete +- Resource extensions +- Tests for each resource + +### Phase 2.4: Polish & Deployment - 4-6 hours +- Manual QA (type-to-confirm, progress UI) +- Load testing (500 items) +- Documentation updates +- Staging → Production deployment + +--- + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Queue timeouts | Chunk processing (10-20 items), timeout config (300s), circuit breaker | +| Progress polling overhead | Limit interval (5s), index queries, cache recent runs | +| Accidental deletes | Type-to-confirm ≥20 items, `ignored_at` flag (restorable), audit trail | +| Job crashes | Fail-soft, BulkOperationRun status tracking, Laravel retry | +| Eligibility misses | Conservative JSONB queries, manual review before hard delete | +| Sync re-adds policies | `ignored_at` filter in SyncPoliciesJob | + +--- + +## Success Criteria + +- ✅ Bulk delete 100 policies in <2 minutes +- ✅ Type-to-confirm prevents accidents (≥20 items) +- ✅ Progress updates every 5-10s +- ✅ Audit log captures per-item outcomes +- ✅ 95%+ operation success rate +- ✅ All P1/P2 tests pass + +--- + +## Next Steps + +1. ✅ Generate plan.md (this file) +2. → Generate research.md (detailed technology findings) +3. → Generate data-model.md (schemas + diagrams) +4. → Generate quickstart.md (developer onboarding) +5. → Run `/speckit.tasks` to create task breakdown +6. → Begin Phase 2.1 implementation + +--- + +**Status**: Plan Complete - Ready for Research +**Created**: 2025-12-22 +**Last Updated**: 2025-12-22 diff --git a/specs/005-bulk-operations/quickstart.md b/specs/005-bulk-operations/quickstart.md new file mode 100644 index 0000000..000c74f --- /dev/null +++ b/specs/005-bulk-operations/quickstart.md @@ -0,0 +1,425 @@ +# Quickstart: Feature 005 - Bulk Operations + +**Feature**: Bulk Operations for Resource Management +**Date**: 2025-12-22 + +--- + +## Overview + +This quickstart guide helps developers get up and running with Feature 005 (Bulk Operations) for local development, testing, and debugging. + +--- + +## Prerequisites + +- Laravel Sail installed and running +- Composer dependencies installed +- NPM dependencies installed +- Database migrated +- At least one Tenant configured +- Sample Policies, PolicyVersions, BackupSets, RestoreRuns seeded + +--- + +## Local Development Setup + +### 1. Start Sail + +```bash +cd /path/to/TenantAtlas +./vendor/bin/sail up -d +``` + +### 2. Run Migrations + +```bash +./vendor/bin/sail artisan migrate +``` + +This creates: +- `bulk_operation_runs` table +- `ignored_at` column on `policies` table + +### 3. Seed Test Data (Optional) + +```bash +./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder +``` + +Creates: +- 100 test policies +- 50 policy versions (some old, some referenced) +- 10 backup sets +- 20 restore runs (mixed statuses) + +### 4. Start Queue Worker + +Bulk operations require queue processing: + +```bash +./vendor/bin/sail artisan queue:work --tries=3 --timeout=300 +``` + +Or run in background with Supervisor (production): + +```bash +./vendor/bin/sail artisan queue:restart +``` + +### 5. Access Filament Panel + +```bash +open http://localhost/admin +``` + +Navigate to: +- **Policies** → Select multiple → Bulk Actions dropdown +- **Policy Versions** → Bulk Prune +- **Backup Sets** → Bulk Delete +- **Restore Runs** → Bulk Delete + +--- + +## Running Tests + +### Unit Tests + +Test individual components (jobs, scopes, helpers): + +```bash +./vendor/bin/sail artisan test tests/Unit/BulkPolicyDeleteJobTest.php +./vendor/bin/sail artisan test tests/Unit/BulkActionPermissionTest.php +./vendor/bin/sail artisan test tests/Unit/BulkEligibilityCheckTest.php +``` + +### Feature Tests + +Test E2E flows (UI → job → database): + +```bash +./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php +./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php +./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php +./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php +``` + +### All Tests + +```bash +./vendor/bin/sail artisan test --filter=Bulk +``` + +### Browser Tests (Pest v4) + +Test UI interactions: + +```bash +./vendor/bin/sail artisan test tests/Browser/BulkOperationsTest.php +``` + +--- + +## Manual Testing Workflow + +### Scenario 1: Bulk Delete Policies (< 20 items) + +1. **Navigate**: Admin → Policies +2. **Select**: Check 10 policies +3. **Action**: Click "Delete" in bulk actions dropdown +4. **Confirm**: Modal appears: "Delete 10 policies?" +5. **Submit**: Click "Confirm" +6. **Verify**: + - Success notification: "Deleted 10 policies" + - Policies have `ignored_at` timestamp set + - Policies still exist in Intune (no Graph DELETE call) + - Audit log entry created + +### Scenario 2: Bulk Delete Policies (≥ 20 items, queued) + +1. **Navigate**: Admin → Policies +2. **Select**: Check 25 policies +3. **Action**: Click "Delete" +4. **Confirm**: Modal requires typing "DELETE" +5. **Type**: Enter "DELETE" (case-sensitive) +6. **Submit**: Click "Confirm" +7. **Verify**: + - Job dispatched to queue + - Progress notification: "Deleting policies... 0/25" + - Notification updates every 5s: "Deleting... 10/25", "20/25" + - Final notification: "Deleted 25 policies" + - `BulkOperationRun` record created with status `completed` + - Audit log entry + +### Scenario 3: Bulk Export to Backup + +1. **Navigate**: Admin → Policies +2. **Select**: Check 30 policies +3. **Action**: Click "Export to Backup" +4. **Form**: + - Backup Set Name: "Production Snapshot" + - Include Assignments: ☑ (if Feature 004 implemented) +5. **Submit**: Click "Confirm" +6. **Verify**: + - Job dispatched + - Progress: "Backing up... 10/30" + - Final: "Backup Set 'Production Snapshot' created (30 items)" + - New `BackupSet` record + - 30 `BackupItem` records + - Audit log entry + +### Scenario 4: Bulk Prune Policy Versions + +1. **Navigate**: Admin → Policy Versions +2. **Filter**: Show only non-current versions older than 90 days +3. **Select**: Check 15 versions +4. **Action**: Click "Delete" +5. **Confirm**: Type "DELETE" +6. **Submit**: Click "Confirm" +7. **Verify**: + - Eligibility check runs + - Eligible versions deleted (hard delete) + - Ineligible versions skipped + - Notification: "Deleted 12 policy versions (3 skipped)" + - Failures array shows skip reasons: + - "Referenced in Backup Set #5" + - "Current version" + - "Too recent (< 90 days)" + +### Scenario 5: Circuit Breaker (abort on >50% fail) + +1. **Setup**: Mock Graph API to fail for 60% of items +2. **Navigate**: Admin → Policies +3. **Select**: Check 100 policies +4. **Action**: Bulk Delete +5. **Verify**: + - Job processes ~50 items + - Detects >50% failure rate + - Aborts remaining items + - Notification: "Bulk operation aborted: 55/100 failures exceeded threshold" + - `BulkOperationRun.status` = `aborted` + +--- + +## Debugging + +### View Queue Jobs + +```bash +# List failed jobs +./vendor/bin/sail artisan queue:failed + +# Retry failed job +./vendor/bin/sail artisan queue:retry + +# Flush failed jobs +./vendor/bin/sail artisan queue:flush +``` + +### Inspect BulkOperationRun Records + +```bash +./vendor/bin/sail tinker +``` + +```php +// Get recent runs +$runs = \App\Models\BulkOperationRun::recent()->limit(10)->get(); + +// View failures +$run = \App\Models\BulkOperationRun::find(1); +dd($run->failures); + +// Check progress +echo "{$run->processed_items}/{$run->total_items} ({$run->progressPercentage()}%)"; +``` + +### View Audit Logs + +```bash +# Via Filament UI +# Navigate to: Admin → Audit Logs → Filter by event: "policies.bulk_deleted" + +# Via Tinker +$logs = \App\Models\AuditLog::where('event', 'policies.bulk_deleted')->get(); +``` + +### Database Queries + +```bash +./vendor/bin/sail artisan tinker +``` + +```php +// Policies marked as ignored +$ignored = \App\Models\Policy::ignored()->count(); + +// Policy versions eligible for pruning +$eligible = \App\Models\PolicyVersion::pruneEligible(90)->count(); + +// Deletable restore runs +$deletable = \App\Models\RestoreRun::deletable()->count(); +``` + +### Test Queue Job Manually + +```bash +./vendor/bin/sail artisan tinker +``` + +```php +use App\Jobs\BulkPolicyDeleteJob; +use App\Models\BulkOperationRun; + +$policyIds = [1, 2, 3]; +$tenantId = 1; +$userId = 1; + +$run = BulkOperationRun::create([ + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'resource' => 'policies', + 'action' => 'delete', + 'status' => 'pending', + 'total_items' => count($policyIds), + 'item_ids' => $policyIds, +]); + +// Dispatch synchronously for debugging +BulkPolicyDeleteJob::dispatchSync($policyIds, $tenantId, $userId, $run->id); + +// Check result +$run->refresh(); +echo $run->summaryText(); +``` + +--- + +## Common Issues & Solutions + +### Issue 1: Type-to-confirm not working + +**Symptom**: Confirm button remains enabled without typing "DELETE" + +**Solution**: Check Filament form validation rule: +```php +->rule('in:DELETE') // Case-sensitive +``` + +### Issue 2: Progress notifications don't update + +**Symptom**: Progress stuck at "0/100" + +**Solution**: +- Ensure queue worker is running: `./vendor/bin/sail artisan queue:work` +- Check Livewire polling: `wire:poll.5s="refresh"` +- Verify BulkOperationRun is updated in job + +### Issue 3: Policies reappear after deletion + +**Symptom**: Deleted policies show up again after sync + +**Solution**: +- Check `ignored_at` is set: `Policy::find($id)->ignored_at` +- Verify SyncPoliciesJob filters: `->whereNull('ignored_at')` + +### Issue 4: Circuit breaker not aborting + +**Symptom**: Job continues despite >50% failures + +**Solution**: Check circuit breaker logic in job: +```php +if ($results['failed'] > count($this->policyIds) * 0.5) { + $run->update(['status' => 'aborted']); + throw new \Exception('Bulk operation aborted: >50% failure rate'); +} +``` + +### Issue 5: Policy versions deleted despite references + +**Symptom**: Referenced versions are deleted + +**Solution**: Verify eligibility scope includes: +```php +->whereDoesntHave('backupItems') +->whereNotIn('id', function ($subquery) { + $subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)")) + ->from('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")); +}); +``` + +--- + +## Performance Benchmarks + +Expected performance (local Sail environment): + +| Operation | Item Count | Duration | Notes | +|-----------|------------|----------|-------| +| Bulk Delete (sync) | 10 | <1s | Immediate feedback | +| Bulk Delete (queued) | 100 | <2min | Chunked, progress updates | +| Bulk Export | 50 | <3min | Includes Graph API calls | +| Bulk Prune | 30 | <30s | Eligibility checks | +| Progress Update | - | 5s | Polling interval | + +--- + +## Code Formatting + +Before committing: + +```bash +./vendor/bin/sail composer pint +``` + +Formats all PHP files per PSR-12. + +--- + +## Next Steps + +1. ✅ Complete Phase 2.1 (Foundation) tasks +2. ✅ Run all tests: `./vendor/bin/sail artisan test --filter=Bulk` +3. ✅ Manual QA: Follow scenarios above +4. ✅ Code review: Check PSR-12, permissions, audit logs +5. ✅ Load testing: Bulk delete 500 items +6. → Deploy to staging +7. → Manual QA on staging +8. → Deploy to production + +--- + +## Useful Commands + +```bash +# Watch queue jobs in real-time +./vendor/bin/sail artisan queue:work --verbose + +# Monitor bulk operations +./vendor/bin/sail artisan tinker +>>> BulkOperationRun::inProgress()->get() + +# Seed more test data +./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder + +# Clear cache +./vendor/bin/sail artisan optimize:clear + +# Restart queue workers (after code changes) +./vendor/bin/sail artisan queue:restart +``` + +--- + +## Resources + +- [Laravel Queue Documentation](https://laravel.com/docs/12.x/queues) +- [Filament Bulk Actions](https://filamentphp.com/docs/4.x/tables/actions#bulk-actions) +- [Livewire Polling](https://livewire.laravel.com/docs/polling) +- [Pest Testing](https://pestphp.com/docs) + +--- + +**Status**: Quickstart Complete +**Next Step**: Update agent context with new learnings diff --git a/specs/005-bulk-operations/research.md b/specs/005-bulk-operations/research.md new file mode 100644 index 0000000..78c0bc4 --- /dev/null +++ b/specs/005-bulk-operations/research.md @@ -0,0 +1,547 @@ +# Research: Feature 005 - Bulk Operations + +**Feature**: Bulk Operations for Resource Management +**Date**: 2025-12-22 +**Research Phase**: Technology Decisions & Best Practices + +--- + +## Research Questions & Findings + +### Q1: How to implement type-to-confirm in Filament bulk actions? + +**Research Goal**: Find a Laravel/Filament-idiomatic way to require explicit confirmation for destructive bulk operations (≥20 items). + +**Findings**: + +Filament BulkActions support conditional forms via `->form()` method: + +```php +Tables\Actions\DeleteBulkAction::make() + ->requiresConfirmation() + ->modalHeading(fn (Collection $records) => + $records->count() >= 20 + ? "⚠️ Delete {$records->count()} policies?" + : "Delete {$records->count()} policies?" + ) + ->form(fn (Collection $records) => + $records->count() >= 20 + ? [ + Forms\Components\TextInput::make('confirm_delete') + ->label('Type DELETE to confirm') + ->rule('in:DELETE') + ->required() + ->helperText('This action cannot be undone.') + ] + : [] + ) + ->action(fn (Collection $records, array $data) => { + // Validation ensures $data['confirm_delete'] === 'DELETE' + // Proceed with bulk delete + }); +``` + +**Key Insight**: Filament's form validation automatically prevents submission if `confirm_delete` doesn't match "DELETE" (case-sensitive). + +**Alternatives Considered**: +- Custom modal component (more code, less reusable) +- JavaScript validation (client-side only, less secure) +- Laravel form request (breaks Filament UX flow) + +**Decision**: Use Filament `->form()` with validation rule. + +--- + +### Q2: How to track progress for queued bulk jobs? + +**Research Goal**: Enable real-time progress tracking for async bulk operations (≥20 items) without blocking UI. + +**Findings**: + +Filament notifications are not reactive by default. Must implement custom progress tracking: + +1. **Create BulkOperationRun model** to persist state: + ```php + Schema::create('bulk_operation_runs', function (Blueprint $table) { + $table->id(); + $table->string('status'); // 'pending', 'running', 'completed', 'failed', 'aborted' + $table->integer('total_items'); + $table->integer('processed_items')->default(0); + $table->integer('succeeded')->default(0); + $table->integer('failed')->default(0); + $table->json('item_ids'); + $table->json('failures')->nullable(); + // ... tenant_id, user_id, resource, action + }); + ``` + +2. **Job updates model after each chunk**: + ```php + collect($this->policyIds)->chunk(10)->each(function ($chunk) use ($run) { + foreach ($chunk as $id) { + // Process item + } + $run->update([ + 'processed_items' => $run->processed_items + $chunk->count(), + // ... succeeded, failed counts + ]); + }); + ``` + +3. **UI polls for updates** via Livewire: + ```blade +
    + Processing... {{ $run->processed_items }}/{{ $run->total_items }} +
    + ``` + +**Alternatives Considered**: +- **Bus::batch()**: Laravel's batch system tracks job progress, but adds complexity: + - Requires job_batches table (already exists in Laravel) + - Each item becomes separate job (overhead for small batches) + - Good for parallelization, overkill for sequential processing + - Decision: **Not needed** - our jobs process items sequentially with chunking + +- **Filament Pulse**: Real-time application monitoring tool + - Too heavy for single-feature progress tracking + - Requires separate service + - Decision: **Rejected** - use custom BulkOperationRun model + +- **Pusher/WebSockets**: Real-time push notifications + - Infrastructure overhead (Pusher subscription or custom WS server) + - Not needed for 5-10s polling interval + - Decision: **Rejected** - Livewire polling sufficient + +**Decision**: BulkOperationRun model + Livewire polling (5s interval). + +--- + +### Q3: How to handle chunked processing in queue jobs? + +**Research Goal**: Process large batches (up to 500 items) without memory exhaustion or timeout. + +**Findings**: + +Laravel Collections provide `->chunk()` method for memory-efficient iteration: + +```php +collect($this->policyIds)->chunk(10)->each(function ($chunk) use (&$results, $run) { + foreach ($chunk as $id) { + try { + // Process item + $results['succeeded']++; + } catch (\Exception $e) { + $results['failed']++; + $results['failures'][] = ['id' => $id, 'reason' => $e->getMessage()]; + } + } + + // Update progress after each chunk (not per-item) + $run->update([ + 'processed_items' => $results['succeeded'] + $results['failed'], + 'succeeded' => $results['succeeded'], + 'failed' => $results['failed'], + 'failures' => $results['failures'], + ]); + + // Circuit breaker: abort if >50% failed + if ($results['failed'] > count($this->policyIds) * 0.5) { + $run->update(['status' => 'aborted']); + throw new \Exception('Bulk operation aborted: >50% failure rate'); + } +}); +``` + +**Key Insights**: +- Chunk size: 10-20 items (balance between DB updates and progress granularity) +- Update BulkOperationRun **after each chunk**, not per-item (reduces DB load) +- Circuit breaker: abort if >50% failures detected mid-process +- Fail-soft: continue processing remaining items on individual failures + +**Alternatives Considered**: +- **Cursor-based chunking**: `Model::chunk(100, function)` + - Good for processing entire tables + - Not needed - we have explicit ID list + +- **Bus::batch()**: Parallel job processing + - Good for independent tasks (e.g., sending emails) + - Our tasks are sequential (delete one, then next) + - Adds complexity without benefit + +- **Database transactions per chunk**: + - Risk: partial failure leaves incomplete state + - Decision: **No transactions** - each item is atomic, fail-soft is intentional + +**Decision**: `collect()->chunk(10)` with after-chunk progress updates. + +--- + +### Q4: How to enforce tenant isolation in bulk jobs? + +**Research Goal**: Ensure bulk operations cannot cross tenant boundaries (critical security requirement). + +**Findings**: + +Laravel Queue jobs serialize model instances poorly (especially Collections). Best practice: + +```php +class BulkPolicyDeleteJob implements ShouldQueue +{ + public function __construct( + public array $policyIds, // array, NOT Collection + public int $tenantId, // explicit tenant ID + public int $actorId, // user ID for audit + public int $bulkOperationRunId // FK to tracking model + ) {} + + public function handle(PolicyRepository $policies): void + { + // Verify all policies belong to tenant (defensive check) + $count = Policy::whereIn('id', $this->policyIds) + ->where('tenant_id', $this->tenantId) + ->count(); + + if ($count !== count($this->policyIds)) { + throw new \Exception('Tenant isolation violation detected'); + } + + // Proceed with bulk operation... + } +} +``` + +**Key Insights**: +- Serialize IDs as `array`, not `Collection` (Collections don't serialize well) +- Pass explicit `tenantId` parameter (don't rely on global scopes) +- Defensive check in job: verify all IDs belong to tenant before processing +- Audit log records `tenantId` and `actorId` for compliance + +**Alternatives Considered**: +- **Global tenant scope**: Rely on Laravel's global scope filtering + - Risk: scope could be disabled/bypassed in job context + - Less explicit, harder to debug + - Decision: **Rejected** - explicit is safer + +- **Pass User model**: `public User $user` + - Serializes entire user object (inefficient) + - User could be deleted before job runs + - Decision: **Rejected** - use `actorId` integer + +**Decision**: Explicit `tenantId` + defensive validation in job. + +--- + +### Q5: How to prevent sync from re-adding "deleted" policies? + +**Research Goal**: User bulk-deletes 50 policies locally, but doesn't want to delete them in Intune. How to prevent SyncPoliciesJob from re-importing them? + +**Findings**: + +Add `ignored_at` timestamp column to policies table: + +```php +// Migration +Schema::table('policies', function (Blueprint $table) { + $table->timestamp('ignored_at')->nullable()->after('deleted_at'); + $table->index('ignored_at'); // query optimization +}); + +// Policy model +public function scopeNotIgnored($query) +{ + return $query->whereNull('ignored_at'); +} + +public function markIgnored(): void +{ + $this->update(['ignored_at' => now()]); +} +``` + +**Modify SyncPoliciesJob**: + +```php +// Before: fetched all policies from Graph, upserted to DB +// After: skip policies where ignored_at IS NOT NULL + +public function handle(PolicySyncService $service): void +{ + $graphPolicies = $service->fetchFromGraph($this->types); + + foreach ($graphPolicies as $graphPolicy) { + $existing = Policy::where('graph_id', $graphPolicy['id']) + ->where('tenant_id', $this->tenantId) + ->first(); + + // Skip if locally ignored + if ($existing && $existing->ignored_at !== null) { + continue; + } + + // Upsert policy... + } +} +``` + +**Key Insight**: `ignored_at` decouples local tracking from Intune state. User can: +- Keep policy in Intune (not deleted remotely) +- Hide policy in TenantPilot (ignored_at set) +- Restore policy later (clear ignored_at) + +**Alternatives Considered**: +- **Soft delete only** (`deleted_at`): + - Problem: Sync doesn't know if user deleted locally or Intune deleted remotely + - Would need separate "deletion source" column + - Decision: **Rejected** - `ignored_at` is clearer intent + +- **Separate "sync_ignore" column**: + - Same outcome as `ignored_at`, but less semantic + - Decision: **Accepted as alias** - `ignored_at` is more descriptive + +**Decision**: Add `ignored_at` timestamp, filter in SyncPoliciesJob. + +--- + +### Q6: How to determine eligibility for Policy Version pruning? + +**Research Goal**: Implement safe "bulk delete old policy versions" that won't break backups/restores. + +**Findings**: + +Eligibility criteria (all must be true): +1. `is_current = false` (not the latest version) +2. `created_at < NOW() - 90 days` (configurable retention period) +3. NOT referenced in `backup_items.policy_version_id` (foreign key check) +4. NOT referenced in `restore_runs.metadata->policy_version_id` (JSONB check) + +Implementation via Eloquent scope: + +```php +// app/Models/PolicyVersion.php +public function scopePruneEligible($query, int $retentionDays = 90) +{ + return $query + ->where('is_current', false) + ->where('created_at', '<', now()->subDays($retentionDays)) + ->whereDoesntHave('backupItems') // FK relationship + ->whereNotIn('id', function ($subquery) { + $subquery->select(DB::raw("CAST(metadata->>'policy_version_id' AS INTEGER)")) + ->from('restore_runs') + ->whereNotNull(DB::raw("metadata->>'policy_version_id'")); + }); +} +``` + +**Bulk prune job**: + +```php +public function handle(): void +{ + foreach ($this->versionIds as $id) { + $version = PolicyVersion::find($id); + + if (!$version) { + $this->failures[] = ['id' => $id, 'reason' => 'Not found']; + continue; + } + + // Check eligibility + $eligible = PolicyVersion::pruneEligible() + ->where('id', $id) + ->exists(); + + if (!$eligible) { + $this->skipped++; + $this->failures[] = ['id' => $id, 'reason' => 'Referenced or too recent']; + continue; + } + + $version->delete(); // hard delete + $this->succeeded++; + } +} +``` + +**Key Insight**: Conservative eligibility check prevents accidental data loss. User sees which versions were skipped and why. + +**Alternatives Considered**: +- **Soft delete first, hard delete later**: Adds complexity, no clear benefit +- **Skip JSONB check**: Risk of breaking restore runs that reference version +- **Admin override**: Allow force-delete even if referenced + - Too dangerous, conflicts with immutability principle + - Decision: **Rejected** + +**Decision**: Eloquent scope `pruneEligible()` with strict checks. + +--- + +### Q7: How to display progress notifications in Filament? + +**Research Goal**: Show real-time progress for bulk operations without blocking UI. + +**Findings**: + +Filament notifications are sent once and don't auto-update. For progress tracking: + +**Option 1: Custom Livewire Component** + +```blade +{{-- resources/views/livewire/bulk-operation-progress.blade.php --}} +
    + @if($run && !$run->isComplete()) +
    +

    {{ $run->action }} in progress...

    +
    +
    +
    +

    {{ $run->processed_items }}/{{ $run->total_items }} items processed

    +
    + @elseif($run && $run->isComplete()) +
    +

    ✅ {{ $run->summaryText() }}

    + @if($run->failed > 0) + View details + @endif +
    + @endif +
    +``` + +```php +// app/Livewire/BulkOperationProgress.php +class BulkOperationProgress extends Component +{ + public int $bulkOperationRunId; + public ?BulkOperationRun $run = null; + + public function mount(int $bulkOperationRunId): void + { + $this->bulkOperationRunId = $bulkOperationRunId; + $this->refresh(); + } + + public function refresh(): void + { + $this->run = BulkOperationRun::find($this->bulkOperationRunId); + + // Stop polling if complete + if ($this->run && $this->run->isComplete()) { + $this->dispatch('bulkOperationComplete', runId: $this->run->id); + } + } + + public function render(): View + { + return view('livewire.bulk-operation-progress'); + } +} +``` + +**Option 2: Filament Infolist Widget** (simpler, more integrated) + +```php +// Display in BulkOperationRun resource ViewRecord page +public static function form(Form $form): Form +{ + return $form + ->schema([ + Infolists\Components\Section::make('Progress') + ->schema([ + Infolists\Components\TextEntry::make('summaryText') + ->label('Status'), + Infolists\Components\ViewEntry::make('progress') + ->view('filament.components.progress-bar') + ->state(fn ($record) => [ + 'percentage' => $record->progressPercentage(), + 'processed' => $record->processed_items, + 'total' => $record->total_items, + ]), + ]) + ->poll('5s') // Filament's built-in polling + ->hidden(fn ($record) => $record->isComplete()), + ]); +} +``` + +**Decision**: Use **Option 1** (custom Livewire component) for flexibility. Embed in: +- Filament notification body (custom view) +- Resource page sidebar +- Dashboard widget (if user wants to monitor all bulk operations) + +**Alternatives Considered**: +- **Pusher/WebSockets**: Too complex for 5s polling +- **JavaScript polling**: Less Laravel-way, harder to test +- **Filament Pulse**: Overkill for single feature + +--- + +## Technology Stack Summary + +| Component | Technology | Justification | +|-----------|------------|---------------| +| Admin Panel | Filament v4 | Built-in bulk actions, forms, notifications | +| Reactive UI | Livewire v3 | Polling, state management, no JS framework needed | +| Queue System | Laravel Queue | Async job processing, retry, failure handling | +| Progress Tracking | BulkOperationRun model + Livewire polling | Persistent state, survives refresh, queryable | +| Type-to-Confirm | Filament form validation | Built-in UI, secure, reusable | +| Tenant Isolation | Explicit tenantId param | Fail-safe, auditable, no implicit scopes | +| Job Chunking | Collection::chunk(10) | Memory-efficient, simple, testable | +| Eligibility Checks | Eloquent scopes | Reusable, composable, database-level filtering | +| Database | PostgreSQL + JSONB | Native JSON support for item_ids, failures | + +--- + +## Best Practices Applied + +### Laravel Conventions +- ✅ Queue jobs implement `ShouldQueue` interface +- ✅ Use Eloquent relationships, not raw queries +- ✅ Form validation via Filament rules +- ✅ PSR-12 code formatting (Laravel Pint) + +### Safety & Security +- ✅ Tenant isolation enforced at job level +- ✅ Type-to-confirm for ≥20 destructive items +- ✅ Fail-soft: continue on individual failures +- ✅ Circuit breaker: abort if >50% fail +- ✅ Audit logging for compliance + +### Performance +- ✅ Chunked processing (10-20 items) +- ✅ Indexed queries (tenant_id, ignored_at) +- ✅ Polling interval: 5s (not 1s spam) +- ✅ JSONB for flexible metadata storage + +### Testing +- ✅ Unit tests for jobs, scopes, eligibility +- ✅ Feature tests for E2E flows +- ✅ Pest assertions for progress tracking +- ✅ Manual QA checklist for UI flows + +--- + +## Rejected Alternatives + +| Alternative | Why Rejected | +|-------------|--------------| +| Bus::batch() | Adds complexity, not needed for sequential processing | +| Filament Pulse | Overkill for single-feature progress tracking | +| Pusher/WebSockets | Infrastructure overhead, 5s polling sufficient | +| Global tenant scopes | Less explicit, harder to debug, security risk | +| Custom modal component | More code, less reusable than Filament form | +| Hard delete without checks | Too risky, violates immutability principle | + +--- + +## Open Questions for Implementation + +1. **Chunk size**: Start with 10, benchmark if needed +2. **Polling interval**: 5s default, make configurable? +3. **Retention period**: 90 days for versions, make configurable? +4. **Max bulk items**: Hard limit at 500? 1000? +5. **Retry failed items**: Future enhancement or MVP? + +--- + +**Status**: Research Complete +**Next Step**: Generate data-model.md diff --git a/specs/005-bulk-operations/spec.md b/specs/005-bulk-operations/spec.md new file mode 100644 index 0000000..1c3cc37 --- /dev/null +++ b/specs/005-bulk-operations/spec.md @@ -0,0 +1,617 @@ +# Feature 005: Bulk Operations for Resource Management + +## Overview +Enable efficient bulk operations across TenantPilot's main resources (Policies, Policy Versions, Backup Sets, Restore Runs) to improve admin productivity and reduce repetitive actions. + +## Problem Statement +Currently, admins must perform actions one-by-one on individual resources: +- Deleting 20 old Policy Versions = 20 clicks + confirmations +- Exporting 50 Policies to a Backup = 50 manual selections +- Cleaning up 30 failed Restore Runs = 30 delete actions + +**This is tedious, error-prone, and time-consuming.** + +**With bulk operations:** +- Select multiple items → single action → confirm → done +- Clear audit trail (one bulk action = one audit event + per-item outcomes) +- Progress notifications for long-running operations +- Consistent UX across all resources + +## Goals +- **Primary**: Implement bulk delete, bulk export, bulk restore (soft delete) for main resources +- **Secondary**: Safety gates (confirmation dialogs, type-to-confirm for destructive ops) +- **Tertiary**: Queue-based processing for large batches with progress tracking +- **Non-Goal**: Bulk edit/update (too complex, deferred to future feature) + +--- + +## User Stories + +### User Story 1 - Bulk Delete Policies (Priority: P1) + +**As an admin**, I want to soft-delete multiple policies **locally in TenantPilot** at once, so I can clean up outdated or test policies efficiently. + +**Important**: This action marks policies as deleted locally, does NOT delete them in Intune. Policies are flagged as `ignored_at` to prevent re-sync. + +**Acceptance Criteria:** +1. **Given** I select 15 policies in the Policies table, + **When** I click "Delete (Local)" in the bulk actions menu, + **Then** a confirmation dialog appears: "Delete 15 policies locally? They will be hidden from listings and ignored in sync." + +2. **Given** I confirm the bulk delete, + **When** the operation completes, + **Then**: + - All 15 policies are flagged (`ignored_at` timestamp set, optionally `deleted_at`) + - A success notification shows: "Deleted 15 policies locally" + - An audit log entry `policies.bulk_deleted_local` is created with policy IDs + - Policies remain in Intune (unchanged) + +3. **Given** I bulk-delete 50 policies, + **When** the operation runs, + **Then** it processes asynchronously via queue (job) with progress notification + +4. **Given** I lack `policies.delete` permission, + **When** I try to bulk-delete, + **Then** the bulk action is disabled/hidden (same permission model as single delete) + +--- + +### User Story 2 - Bulk Export Policies to Backup (Priority: P1) + +**As an admin**, I want to export multiple policies to a new Backup Set in one action, so I can quickly snapshot a subset of policies. + +**Acceptance Criteria:** +1. **Given** I select 25 policies, + **When** I click "Export to Backup", + **Then** a dialog prompts: "Backup Set Name" + "Include Assignments?" checkbox + +2. **Given** I confirm the export, + **When** the backup job runs, + **Then**: + - A new Backup Set is created + - 25 Backup Items are captured (one per policy) + - Progress notification: "Backing up... 10/25" + - Final notification: "Backup Set 'Production Snapshot' created (25 items)" + +3. **Given** 3 of 25 policies fail to backup (Graph error), + **When** the job completes, + **Then**: + - 22 items succeed, 3 fail + - Notification: "Backup completed: 22 succeeded, 3 failed" + - Audit log records per-item outcomes + +--- + +### User Story 3 - Bulk Delete Policy Versions (Priority: P2) + +**As an admin**, I want to bulk-delete old policy versions to free up database space, respecting retention policies. + +**Important**: Policy Versions are immutable snapshots. Deletion only allowed if version is NOT referenced (no active Backup Items, Restore Runs, or audit trails) and meets retention threshold (e.g., >90 days old). + +**Acceptance Criteria:** +1. **Given** I select 30 policy versions older than 90 days, + **When** I click "Delete", + **Then** confirmation dialog: "Delete 30 policy versions? This is permanent and cannot be undone." + +2. **Given** I confirm, + **When** the operation completes, + **Then**: + - System checks each version: is_current=false + not referenced + age >90 days + - Eligible versions are hard-deleted + - Ineligible versions are skipped with reason (e.g., "Referenced by Backup Set ID 5") + - Success notification: "Deleted 28 policy versions (2 skipped)" + - Audit log: `policy_versions.bulk_pruned` with version IDs + skip reasons + +3. **Given** I lack `policy_versions.prune` permission, + **When** I try to bulk-delete, + **Then** the bulk action is hidden + +--- + +### User Story 4 - Bulk Delete Restore Runs (Priority: P2) + +**As an admin**, I want to bulk-delete completed or failed Restore Runs to declutter the history. + +**Acceptance Criteria:** +1. **Given** I select 20 restore runs (status: completed/failed), + **When** I click "Delete", + **Then** confirmation: "Delete 20 restore runs? Historical data will be removed." + +2. **Given** I confirm, + **When** the operation completes, + **Then**: + - 20 restore runs are soft-deleted + - Notification: "Deleted 20 restore runs" + - Audit log: `restore_runs.bulk_deleted` + +3. **Given** I select restore runs with mixed statuses (running + completed), + **When** I attempt bulk delete, + **Then** only completed/failed runs are deleted (running ones skipped with warning) + +--- + +### User Story 5 - Bulk Delete with Type-to-Confirm (Priority: P1) + +**As an admin**, I want extra confirmation for large destructive operations, so I don't accidentally delete important data. + +**Acceptance Criteria:** +1. **Given** I bulk-delete ≥20 items, + **When** the confirmation dialog appears, + **Then** I must type "DELETE" in a text field to enable the confirm button + +2. **Given** I type an incorrect word (e.g., "delete" lowercase), + **When** I try to confirm, + **Then** the button remains disabled with error: "Type DELETE to confirm" + +3. **Given** I type "DELETE" correctly, + **When** I click confirm, + **Then** the bulk operation proceeds + +--- + +### User Story 6 - Bulk Operation Progress Tracking (Priority: P2) + +**As an admin**, I want to see real-time progress for bulk operations, so I know the system is working. + +**Acceptance Criteria:** +1. **Given** I bulk-delete 100 policies, + **When** the job starts, + **Then** a Filament notification shows: "Deleting policies... 0/100" + +2. **Given** the job processes items, + **When** progress updates, + **Then** the notification updates every 5 seconds: "Deleting... 45/100" + +3. **Given** the job completes, + **When** all items are processed, + **Then**: + - Final notification: "Deleted 98 policies (2 failed)" + - Clickable link: "View details" → opens audit log entry + +--- + +## Functional Requirements + +### General Bulk Operations + +**FR-005.1**: System MUST provide bulk action checkboxes on table rows for: +- Policies +- Policy Versions +- Backup Sets +- Restore Runs + +**FR-005.2**: Bulk actions menu MUST appear when ≥1 item is selected, showing: +- Action name (e.g., "Delete") +- Count badge (e.g., "3 selected") +- Disabled state if user lacks permission + +**FR-005.3**: System MUST enforce same permissions for bulk actions as single actions (e.g., `policies.delete` for bulk delete). + +**FR-005.4**: Bulk operations processing ≥20 items MUST run via Laravel Queue (async job) using Bus::batch() or chunked processing (batches of 10-20 items). + +**FR-005.4a**: System MUST create a `bulk_operation_runs` table to track progress: +```php +Schema::create('bulk_operation_runs', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained(); + $table->foreignId('user_id')->constrained(); + $table->string('resource'); // 'policies', 'policy_versions', etc. + $table->string('action'); // 'delete', 'export', etc. + $table->string('status'); // 'running', 'completed', 'failed', 'aborted' + $table->integer('total_items'); + $table->integer('processed_items')->default(0); + $table->integer('succeeded')->default(0); + $table->integer('failed')->default(0); + $table->integer('skipped')->default(0); + $table->json('item_ids'); // array of IDs + $table->json('failures')->nullable(); // [{id, reason}, ...] + $table->foreignId('audit_log_id')->nullable()->constrained(); + $table->timestamps(); +}); + +**FR-005.5**: Bulk operations <20 items MAY run synchronously (immediate feedback). + +### Confirmation Dialogs + +**FR-005.6**: Confirmation dialog MUST show: +- Action description: "Delete 15 policies?" +- Impact warning: "This moves them to trash." or "This is permanent." +- Item count badge +- Cancel/Confirm buttons + +**FR-005.7**: For destructive operations with ≥20 items, dialog MUST require typing "DELETE" (case-sensitive) to enable confirm button. + +**FR-005.8**: For non-destructive operations (export, restore), typing confirmation is NOT required. + +### Audit Logging + +**FR-005.9**: System MUST create one audit log entry per bulk operation with: +- Event type: `{resource}.bulk_{action}` (e.g., `policies.bulk_deleted`) +- Actor (user ID/email) +- Metadata: `{ item_count: 15, item_ids: [...], outcomes: {...} }` + +**FR-005.10**: Audit log MUST record per-item outcomes: +```json +{ + "item_count": 15, + "succeeded": 13, + "failed": 2, + "skipped": 0, + "failures": [ + {"id": "abc-123", "reason": "Graph API error: 503"}, + {"id": "def-456", "reason": "Policy not found"} + ] +} +``` + +### Progress Tracking + +**FR-005.11**: For queued bulk jobs (≥20 items), system MUST emit progress via: +- `BulkOperationRun` model (status, processed_items updated after each batch) +- Livewire polling on UI (every 3-5 seconds) to fetch updated progress +- Filament notification with progress bar: + - Initial: "Processing... 0/{count}" + - Periodic: "Processing... {done}/{count}" + - Final: "Completed: {succeeded} succeeded, {failed} failed" + +**FR-005.11a**: UI MUST poll `BulkOperationRun` status endpoint (e.g., `/api/bulk-operations/{id}/status`) or use Livewire wire:poll to refresh progress. + +**FR-005.12**: Final notification MUST include link to audit log entry for details. + +**FR-005.13**: If job fails catastrophically (exception), notification MUST show: "Bulk operation failed. Contact support." + +### Error Handling + +**FR-005.14**: System MUST continue processing remaining items if one fails (fail-soft, not fail-fast). + +**FR-005.15**: System MUST collect all failures and report them in final notification + audit log. + +**FR-005.16**: If >50% of items fail, system MUST: +- Abort processing remaining items (status = `aborted`) +- Final notification: "Bulk operation aborted: {failed}/{total} failures exceeded threshold" +- Admin can manually trigger "Retry Failed Items" from BulkOperationRun detail view (future enhancement) + +--- + +## Bulk Actions by Resource + +### Policies Resource + +| Action | Priority | Destructive | Scope | Threshold for Queue | Type-to-Confirm | +|--------|----------|-------------|-------|---------------------|-----------------| +| Delete (local) | P1 | Yes (local only) | TenantPilot DB | ≥20 | ≥20 | +| Export to Backup | P1 | No | TenantPilot DB | ≥20 | No | +| Force Delete | P3 | Yes (local) | TenantPilot DB | ≥10 | Always | +| Restore (untrash) | P3 | No | TenantPilot DB | ≥50 | No | +| Sync (re-fetch) | P4 | No | Graph read | ≥50 | No | + +**FR-005.17**: Bulk Delete for Policies MUST set `ignored_at` timestamp (prevents re-sync) + optionally `deleted_at` (soft delete). Does NOT call Graph DELETE. + +**FR-005.17a**: Sync Job MUST skip policies where `ignored_at IS NOT NULL`. + +**FR-005.18**: Bulk Export to Backup MUST prompt for: +- Backup Set name (auto-generated default: "Bulk Export {date}") +- "Include Assignments" checkbox (if Feature 004 implemented) + +**FR-005.19**: Bulk Sync MUST queue a SyncPoliciesJob for each selected policy. + +### Policy Versions Resource + +| Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | +|--------|----------|-------------|---------------------|-----------------| +| Delete | P2 | Yes | ≥20 | ≥20 | +| Export to Backup | P3 | No | ≥20 | No | + +**FR-005.20**: Bulk Delete for Policy Versions MUST: +- Check eligibility: `is_current = false` AND `created_at < NOW() - 90 days` AND NOT referenced +- Referenced = exists in `backup_items.policy_version_id` OR `restore_runs.metadata` OR critical audit logs +- Hard-delete eligible versions +- Skip ineligible with reason: "Referenced", "Too recent", "Current version" + +**FR-005.21**: System MUST require `policy_versions.prune` permission (separate from `policy_versions.delete`). + +### Backup Sets Resource + +| Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | +|--------|----------|-------------|---------------------|-----------------| +| Delete | P2 | Yes | ≥10 | ≥10 | +| Archive (flag) | P3 | No | N/A | No | + +**FR-005.22**: Bulk Delete for Backup Sets MUST cascade-delete related Backup Items. + +**FR-005.23**: Bulk Archive MUST set `archived_at` timestamp (soft flag, keeps data). + +### Restore Runs Resource + +| Action | Priority | Destructive | Threshold for Queue | Type-to-Confirm | +|--------|----------|-------------|---------------------|-----------------| +| Delete | P2 | Yes | ≥20 | ≥20 | +| Rerun | P3 | No | N/A | No | +| Cancel (abort) | P3 | No | N/A | No | + +**FR-005.24**: Bulk Delete for Restore Runs MUST soft-delete. + +**FR-005.25**: Bulk Delete MUST skip runs with status `running` (show warning in results). + +**FR-005.26**: Bulk Rerun (if T156 implemented) MUST create new RestoreRun for each selected run. + +--- + +## Non-Functional Requirements + +**NFR-005.1**: Bulk operations MUST handle up to 500 items per operation without timeout. + +**NFR-005.2**: Queue jobs MUST process items in batches of 10-20 (configurable) to avoid memory issues. + +**NFR-005.3**: Progress notifications MUST update at least every 10 seconds (avoid spamming). + +**NFR-005.4**: UI MUST remain responsive during bulk operations (no blocking spinner). + +**NFR-005.5**: Bulk operations MUST respect tenant isolation (only act on current tenant's data). + +--- + +## Technical Implementation + +### Filament Bulk Actions Setup + +```php +// Example: PolicyResource.php +public static function table(Table $table): Table +{ + return $table + ->columns([...]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make() + ->requiresConfirmation() + ->modalHeading(fn (Collection $records) => "Delete {$records->count()} policies?") + ->modalDescription('This moves them to trash.') + ->action(fn (Collection $records) => + BulkPolicyDeleteJob::dispatch($records->pluck('id')) + ), + + Tables\Actions\BulkAction::make('export_to_backup') + ->label('Export to Backup') + ->icon('heroicon-o-arrow-down-tray') + ->form([ + Forms\Components\TextInput::make('backup_name') + ->default('Bulk Export ' . now()->format('Y-m-d')), + Forms\Components\Checkbox::make('include_assignments') + ->label('Include Assignments & Scope Tags'), + ]) + ->action(fn (Collection $records, array $data) => + BulkPolicyExportJob::dispatch($records->pluck('id'), $data) + ), + ]), + ]); +} +``` + +### Queue Job Structure + +```php +// app/Jobs/BulkPolicyDeleteJob.php +class BulkPolicyDeleteJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + public function __construct( + public array $policyIds, // array, NOT Collection (serialization) + public int $tenantId, // explicit tenant isolation + public int $actorId, // user ID, not just email + public int $bulkOperationRunId // FK to bulk_operation_runs table + ) {} + + public function handle( + AuditLogger $audit, + PolicyRepository $policies + ): void { + $run = BulkOperationRun::find($this->bulkOperationRunId); + $run->update(['status' => 'running']); + + $results = ['succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'failures' => []]; + + // Process in chunks for memory efficiency + collect($this->policyIds)->chunk(10)->each(function ($chunk) use (&$results, $policies, $run) { + foreach ($chunk as $id) { + try { + $policies->markIgnored($id); // set ignored_at + $results['succeeded']++; + } catch (\Exception $e) { + $results['failed']++; + $results['failures'][] = ['id' => $id, 'reason' => $e->getMessage()]; + } + } + + // Update progress after each chunk + $run->update([ + 'processed_items' => $results['succeeded'] + $results['failed'], + 'succeeded' => $results['succeeded'], + 'failed' => $results['failed'], + 'failures' => $results['failures'], + ]); + + // Circuit breaker: abort if >50% failed + if ($results['failed'] > count($this->policyIds) * 0.5) { + $run->update(['status' => 'aborted']); + throw new \Exception('Bulk operation aborted: >50% failure rate'); + } + }); + + $auditLogId = $audit->log('policies.bulk_deleted_local', [ + 'item_count' => count($this->policyIds), + 'outcomes' => $results, + 'bulk_operation_run_id' => $this->bulkOperationRunId, + ]); + + $run->update(['status' => 'completed', 'audit_log_id' => $auditLogId]); + } +} +``` + +### Type-to-Confirm Modal + +```php +Tables\Actions\DeleteBulkAction::make() + ->requiresConfirmation() + ->modalHeading(fn (Collection $records) => + $records->count() >= 20 + ? "⚠️ Delete {$records->count()} policies?" + : "Delete {$records->count()} policies?" + ) + ->form(fn (Collection $records) => + $records->count() >= 20 + ? [ + Forms\Components\TextInput::make('confirm_delete') + ->label('Type DELETE to confirm') + ->rule('in:DELETE') + ->required() + ->helperText('This action cannot be undone.') + ] + : [] + ) +``` + +--- + +## UI/UX Patterns + +### Bulk Action Menu + +``` +┌────────────────────────────────────────────┐ +│ ☑ Select All (50 items) │ +│ │ +│ 15 selected │ +│ [Delete] [Export to Backup] [More ▾] │ +└────────────────────────────────────────────┘ +``` + +### Confirmation Dialog (≥20 items) + +``` +⚠️ Delete 25 policies? + +This moves them to trash. You can restore them later. + +Type DELETE to confirm: +[________________] + +[Cancel] [Confirm] (disabled until typed) +``` + +### Progress Notification + +``` +🔄 Deleting policies... + ████████████░░░░░░░░ 45 / 100 + +[View Details] +``` + +### Final Notification + +``` +✅ Deleted 98 policies + +2 items failed (click for details) + +[View Audit Log] [Dismiss] +``` + +--- + +## Testing Strategy + +### Unit Tests +- `BulkPolicyDeleteJobTest`: Mock policy repo, test outcomes +- `BulkActionPermissionTest`: Verify permission checks +- `ConfirmationDialogTest`: Test type-to-confirm logic + +### Feature Tests +- `BulkDeletePoliciesTest`: E2E flow (select → confirm → verify soft delete) +- `BulkExportToBackupTest`: E2E export with job queue +- `BulkProgressNotificationTest`: Verify progress events emitted + +### Load Tests +- 500 items bulk delete (should complete in <5 minutes) +- 1000 items bulk export (queue + batch processing) + +### Manual QA +- Select 30 policies → bulk delete → verify trash +- Export 50 policies → verify backup set created +- Test type-to-confirm with correct/incorrect input +- Force job failure → verify error handling + +--- + +## Rollout Plan + +### Phase 1: Foundation (P1 Actions) +- Policies: Bulk Delete, Bulk Export +- Confirmation dialogs + type-to-confirm +- **Duration**: ~8-12 hours + +### Phase 2: Queue + Progress (P1 Features) +- Queue jobs for ≥20 items +- Progress notifications +- Audit logging +- **Duration**: ~8-10 hours + +### Phase 3: Additional Resources (P2 Actions) +- Policy Versions: Bulk Delete +- Restore Runs: Bulk Delete +- Backup Sets: Bulk Delete +- **Duration**: ~6-8 hours + +### Phase 4: Advanced Actions (P3 Optional) +- Bulk Force Delete +- Bulk Restore (untrash) +- Bulk Rerun (depends on T156) +- **Duration**: ~4-6 hours per action + +--- + +## Dependencies +- Laravel Queue (✅ configured) +- Filament Bulk Actions (✅ built-in) +- Feature 001: Audit Logger (✅ complete) + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Large batches cause timeout | Queue jobs + chunked processing (10-20 items/batch) + Bus::batch() | +| User accidentally deletes 500 items | Type-to-confirm for ≥20 items + `ignored_at` flag (restorable) | +| Job fails mid-process | Fail-soft, log failures in `bulk_operation_runs`, abort if >50% fail | +| UI becomes unresponsive | Async jobs + Livewire polling for progress | +| Policy Versions deleted while referenced | Eligibility check: not referenced in backups/restores/audits | +| Sync re-adds "deleted" policies | `ignored_at` flag prevents re-sync | +| Progress notifications don't update | `BulkOperationRun` model + polling required (not automatic Filament feature) | + +--- + +## Success Criteria + +1. ✅ Bulk delete 100 policies in <2 minutes (queued) +2. ✅ Type-to-confirm prevents accidental deletes +3. ✅ Progress notifications update every 5-10s +4. ✅ Audit log captures per-item outcomes +5. ✅ 95%+ success rate for bulk operations +6. ✅ Tests cover all P1/P2 actions + +--- + +## Open Questions +1. Should we add bulk "Tag" (apply labels/categories)? +2. Bulk "Clone" for policies (create duplicates)? +3. Max items per bulk operation (hard limit)? +4. Retry failed items in bulk operation? + +--- + +**Status**: Draft for Review +**Created**: 2025-12-22 +**Author**: AI + Ahmed +**Next Steps**: Review → Plan → Tasks diff --git a/specs/005-bulk-operations/tasks.md b/specs/005-bulk-operations/tasks.md new file mode 100644 index 0000000..1e5ff4d --- /dev/null +++ b/specs/005-bulk-operations/tasks.md @@ -0,0 +1,485 @@ +# Tasks: Feature 005 - Bulk Operations + +**Branch**: `feat/005-bulk-operations` | **Date**: 2025-12-22 +**Input**: [spec.md](./spec.md), [plan.md](./plan.md), [data-model.md](./data-model.md), [research.md](./research.md) + +## Task Format + +- **Checkbox**: `- [ ]` for incomplete, `- [x]` for complete +- **Task ID**: Sequential T001, T002, T003... +- **[P] marker**: Task can run in parallel (different files, no blocking dependencies) +- **[Story] label**: User story tag (US1, US2, US3...) - omit for Setup/Foundational/Polish phases +- **File path**: Always include exact file path in description + +## Phase 1: Setup (Project Initialization) + +**Purpose**: Database schema and base infrastructure for bulk operations + +- [ ] T001 Create migration for `bulk_operation_runs` table in database/migrations/YYYY_MM_DD_HHMMSS_create_bulk_operation_runs_table.php +- [ ] T002 Create migration to add `ignored_at` column to policies table in database/migrations/YYYY_MM_DD_HHMMSS_add_ignored_at_to_policies_table.php +- [ ] T003 [P] Create BulkOperationRun model in app/Models/BulkOperationRun.php +- [ ] T004 [P] Create BulkOperationService in app/Services/BulkOperationService.php +- [ ] T005 Run migrations and verify schema: `./vendor/bin/sail artisan migrate` +- [ ] T006 Run Pint formatting: `./vendor/bin/sail composer pint` + +**Checkpoint**: Database ready, base models created + +--- + +## Phase 2: Foundational (Shared Components) + +**Purpose**: Core components used by ALL bulk operations - MUST complete before user stories + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [ ] T007 Extend Policy model with `ignored_at` scope and methods in app/Models/Policy.php +- [ ] T008 [P] Extend PolicyVersion model with `pruneEligible()` scope in app/Models/PolicyVersion.php +- [ ] T009 [P] Extend RestoreRun model with `deletable()` scope in app/Models/RestoreRun.php +- [ ] T010 Extend AuditLogger service to support bulk operation events in app/Services/Audit/AuditLogger.php (or equivalent) +- [ ] T011 Update SyncPoliciesJob to filter by `ignored_at IS NULL` in app/Jobs/SyncPoliciesJob.php +- [ ] T012 Create BulkOperationRun factory in database/factories/BulkOperationRunFactory.php +- [ ] T013 Create test seeder BulkOperationsTestSeeder in database/seeders/BulkOperationsTestSeeder.php + +**Checkpoint**: Foundation ready - user story implementation can now begin in parallel + +--- + +## Phase 3: User Story 1 - Bulk Delete Policies (Priority: P1) 🎯 MVP + +**Goal**: Enable admins to soft-delete multiple policies locally with `ignored_at` flag, preventing re-sync + +**Independent Test**: Select 15 policies → bulk delete → verify `ignored_at` set, policies hidden from listings, audit log created, Intune unchanged + +### Tests for User Story 1 + +- [ ] T014 [P] [US1] Write unit test for BulkPolicyDeleteJob in tests/Unit/BulkPolicyDeleteJobTest.php +- [ ] T015 [P] [US1] Write feature test for bulk delete <20 items (sync) in tests/Feature/BulkDeletePoliciesTest.php +- [ ] T016 [P] [US1] Write feature test for bulk delete ≥20 items (async) in tests/Feature/BulkDeletePoliciesAsyncTest.php +- [ ] T017 [P] [US1] Write permission test for bulk delete in tests/Unit/BulkActionPermissionTest.php + +### Implementation for User Story 1 + +- [ ] T018 [P] [US1] Create BulkPolicyDeleteJob in app/Jobs/BulkPolicyDeleteJob.php +- [ ] T019 [US1] Add bulk delete action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [ ] T020 [US1] Implement type-to-confirm modal (≥20 items) in PolicyResource bulk action +- [ ] T021 [US1] Wire BulkOperationService to create tracking record and dispatch job +- [ ] T022 [US1] Test bulk delete with 10 policies (sync, manual QA) +- [ ] T023 [US1] Test bulk delete with 25 policies (async, manual QA) +- [ ] T024 [US1] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php` +- [ ] T025 [US1] Verify audit log entry created with correct metadata + +**Checkpoint**: Bulk delete policies working (sync + async), audit logged, tests passing + +--- + +## Phase 4: User Story 2 - Bulk Export Policies to Backup (Priority: P1) + +**Goal**: Enable admins to export multiple policies to a new Backup Set with progress tracking + +**Independent Test**: Select 25 policies → export to backup → verify BackupSet created, 25 BackupItems exist, progress notification shown + +### Tests for User Story 2 + +- [ ] T026 [P] [US2] Write unit test for BulkPolicyExportJob in tests/Unit/BulkPolicyExportJobTest.php +- [ ] T027 [P] [US2] Write feature test for bulk export in tests/Feature/BulkExportToBackupTest.php +- [ ] T028 [P] [US2] Write test for export with failures (3/25 fail) in tests/Feature/BulkExportFailuresTest.php + +### Implementation for User Story 2 + +- [ ] T029 [P] [US2] Create BulkPolicyExportJob in app/Jobs/BulkPolicyExportJob.php +- [ ] T030 [US2] Add bulk export action to PolicyResource in app/Filament/Resources/PolicyResource.php +- [ ] T031 [US2] Create export form with backup_name and include_assignments fields +- [ ] T032 [US2] Implement export logic (create BackupSet, capture BackupItems) +- [ ] T033 [US2] Handle partial failures (some policies fail to backup) +- [ ] T034 [US2] Test export with 30 policies (manual QA) +- [ ] T035 [US2] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkExportToBackupTest.php` + +**Checkpoint**: Bulk export working, BackupSets created, failures handled gracefully + +--- + +## Phase 5: User Story 5 - Type-to-Confirm (Priority: P1) + +**Goal**: Require typing "DELETE" for destructive operations with ≥20 items + +**Independent Test**: Bulk delete 25 policies → modal requires "DELETE" → button disabled until correct input → operation proceeds + +**Note**: This is implemented within US1 (T020) but tested separately here + +### Tests for User Story 5 + +- [ ] T036 [P] [US5] Write test for type-to-confirm with correct input in tests/Feature/BulkTypeToConfirmTest.php +- [ ] T037 [P] [US5] Write test for type-to-confirm with incorrect input (lowercase "delete") +- [ ] T038 [P] [US5] Write test for type-to-confirm disabled for <20 items + +### Validation for User Story 5 + +- [ ] T039 [US5] Manual test: Bulk delete 10 policies → confirm without typing (should work) +- [ ] T040 [US5] Manual test: Bulk delete 25 policies → type "delete" → button stays disabled +- [ ] T041 [US5] Manual test: Bulk delete 25 policies → type "DELETE" → button enables, operation proceeds +- [ ] T042 [US5] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkTypeToConfirmTest.php` + +**Checkpoint**: Type-to-confirm working correctly for all thresholds + +--- + +## Phase 6: User Story 6 - Progress Tracking (Priority: P2) + +**Goal**: Show real-time progress for queued bulk operations with Livewire polling + +**Independent Test**: Bulk delete 100 policies → progress notification updates every 5s → final notification shows outcomes + +### Tests for User Story 6 + +- [ ] T043 [P] [US6] Write test for progress updates in BulkOperationRun in tests/Unit/BulkOperationRunProgressTest.php +- [ ] T044 [P] [US6] Write feature test for progress notification in tests/Feature/BulkProgressNotificationTest.php +- [ ] T045 [P] [US6] Write test for circuit breaker (abort >50% fail) in tests/Unit/CircuitBreakerTest.php + +### Implementation for User Story 6 + +- [ ] T046 [P] [US6] Create Livewire progress component in app/Livewire/BulkOperationProgress.php +- [ ] T047 [P] [US6] Create progress view in resources/views/livewire/bulk-operation-progress.blade.php +- [ ] T048 [US6] Update BulkPolicyDeleteJob to emit progress after each chunk +- [ ] T049 [US6] Update BulkPolicyExportJob to emit progress after each chunk +- [ ] T050 [US6] Implement circuit breaker logic (abort if >50% fail) in jobs +- [ ] T051 [US6] Add progress polling to Filament notifications or sidebar widget +- [ ] T052 [US6] Test progress with 100 policies (manual QA, observe updates) +- [ ] T053 [US6] Test circuit breaker with mock failures (manual QA) +- [ ] T054 [US6] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkProgressNotificationTest.php` + +**Checkpoint**: Progress tracking working, polling updates UI, circuit breaker aborts high-failure jobs + +--- + +## Phase 7: User Story 3 - Bulk Delete Policy Versions (Priority: P2) + +**Goal**: Enable admins to prune old policy versions that are NOT referenced and meet retention threshold (>90 days) + +**Independent Test**: Select 30 old versions → bulk prune → verify eligible deleted, ineligible skipped with reasons + +### Tests for User Story 3 + +- [ ] T055 [P] [US3] Write unit test for pruneEligible() scope in tests/Unit/PolicyVersionEligibilityTest.php +- [ ] T056 [P] [US3] Write unit test for BulkPolicyVersionPruneJob in tests/Unit/BulkPolicyVersionPruneJobTest.php +- [ ] T057 [P] [US3] Write feature test for bulk prune in tests/Feature/BulkPruneVersionsTest.php +- [ ] T058 [P] [US3] Write test for skip reasons (referenced, too recent, current) in tests/Feature/BulkPruneSkipReasonsTest.php + +### Implementation for User Story 3 + +- [ ] T059 [P] [US3] Create BulkPolicyVersionPruneJob in app/Jobs/BulkPolicyVersionPruneJob.php +- [ ] T060 [US3] Add bulk delete action to PolicyVersionResource in app/Filament/Resources/PolicyVersionResource.php +- [ ] T061 [US3] Implement eligibility check per version in job (reuse pruneEligible scope) +- [ ] T062 [US3] Collect skip reasons for ineligible versions +- [ ] T063 [US3] Add type-to-confirm for ≥20 versions +- [ ] T064 [US3] Test prune with 30 versions (15 eligible, 15 ineligible) - manual QA +- [ ] T065 [US3] Verify skip reasons in notification and audit log +- [ ] T066 [US3] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkPruneVersionsTest.php` + +**Checkpoint**: Policy versions pruning working, eligibility enforced, skip reasons logged + +--- + +## Phase 8: User Story 4 - Bulk Delete Restore Runs (Priority: P2) + +**Goal**: Enable admins to delete completed/failed restore runs to declutter history + +**Independent Test**: Select 20 completed runs → bulk delete → verify soft-deleted, running runs skipped + +### Tests for User Story 4 + +- [ ] T067 [P] [US4] Write unit test for deletable() scope in tests/Unit/RestoreRunDeletableTest.php +- [ ] T068 [P] [US4] Write unit test for BulkRestoreRunDeleteJob in tests/Unit/BulkRestoreRunDeleteJobTest.php +- [ ] T069 [P] [US4] Write feature test for bulk delete restore runs in tests/Feature/BulkDeleteRestoreRunsTest.php +- [ ] T070 [P] [US4] Write test for mixed statuses (skip running) in tests/Feature/BulkDeleteMixedStatusTest.php + +### Implementation for User Story 4 + +- [ ] T071 [P] [US4] Create BulkRestoreRunDeleteJob in app/Jobs/BulkRestoreRunDeleteJob.php +- [ ] T072 [US4] Add bulk delete action to RestoreRunResource in app/Filament/Resources/RestoreRunResource.php +- [ ] T073 [US4] Filter by deletable() scope (completed, failed, aborted only) +- [ ] T074 [US4] Skip running restore runs with warning +- [ ] T075 [US4] Add type-to-confirm for ≥20 runs +- [ ] T076 [US4] Test delete with 20 completed + 5 running (manual QA, verify 5 skipped) +- [ ] T077 [US4] Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteRestoreRunsTest.php` + +**Checkpoint**: Restore runs bulk delete working, running runs protected, skip warnings shown + +--- + +## Phase 9: Additional Resource - Bulk Delete Backup Sets (Priority: P2) + +**Goal**: Enable admins to delete backup sets with cascade-delete of backup items + +**Independent Test**: Select 10 backup sets → bulk delete → verify sets deleted, items cascade-deleted + +### Tests for Additional Resource + +- [ ] T078 [P] Write unit test for BulkBackupSetDeleteJob in tests/Unit/BulkBackupSetDeleteJobTest.php +- [ ] T079 [P] Write feature test for bulk delete backup sets in tests/Feature/BulkDeleteBackupSetsTest.php + +### Implementation for Additional Resource + +- [ ] T080 [P] Create BulkBackupSetDeleteJob in app/Jobs/BulkBackupSetDeleteJob.php +- [ ] T081 Add bulk delete action to BackupSetResource in app/Filament/Resources/BackupSetResource.php +- [ ] T082 Verify cascade-delete logic for backup items (should be automatic via foreign key) +- [ ] T083 Add type-to-confirm for ≥10 sets +- [ ] T084 Test delete with 15 backup sets (manual QA) +- [ ] T085 Run tests: `./vendor/bin/sail artisan test tests/Feature/BulkDeleteBackupSetsTest.php` + +**Checkpoint**: Backup sets bulk delete working, cascade-delete verified + +--- + +## Phase 10: Polish & Cross-Cutting Concerns + +**Purpose**: Documentation, cleanup, performance optimization + +- [ ] T086 [P] Update README.md with bulk operations feature description +- [ ] T087 [P] Update quickstart.md with manual testing scenarios (already done in planning) +- [ ] T088 Code cleanup: Remove debug statements, refactor duplicated logic +- [ ] T089 Performance test: Bulk delete 500 policies (should complete <5 minutes) +- [ ] T090 Load test: Concurrent bulk operations (2-3 admins, different resources) +- [ ] T091 [P] Security review: Verify tenant isolation in all jobs +- [ ] T092 [P] Permission audit: Verify all bulk actions respect RBAC +- [ ] T093 Run full test suite: `./vendor/bin/sail artisan test` +- [ ] T094 Run Pint formatting: `./vendor/bin/sail composer pint` +- [ ] T095 Manual QA checklist: Complete all scenarios from quickstart.md +- [ ] T096 Document configuration options (chunk size, polling interval, retention days) +- [ ] T097 Create BulkOperationRun resource page in Filament (view progress, retry failed) +- [ ] T098 Add bulk operation metrics to dashboard (total runs, success rate) + +**Checkpoint**: Feature polished, tested, documented, ready for staging deployment + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +``` +Setup (Phase 1) + ↓ +Foundational (Phase 2) ← BLOCKS all user stories + ↓ +┌───────────────────────────────────┐ +│ User Stories (Parallel Capable) │ +├───────────────────────────────────┤ +│ US1: Bulk Delete Policies (P1) │ ← MVP +│ US2: Bulk Export (P1) │ ← MVP +│ US5: Type-to-Confirm (P1) │ ← Embedded in US1 +├───────────────────────────────────┤ +│ US6: Progress Tracking (P2) │ ← Enhances US1, US2 +│ US3: Prune Versions (P2) │ +│ US4: Delete Runs (P2) │ +│ Additional: Delete Sets (P2) │ +└───────────────────────────────────┘ + ↓ +Polish (Phase 10) +``` + +### User Story Dependencies + +- **US1 (P1)**: Depends on Foundational (T007, T010, T011). Fully independent after that. +- **US2 (P1)**: Depends on Foundational (T010). Fully independent after that. +- **US5 (P1)**: Implemented within US1 (T020), tested separately. +- **US6 (P2)**: Depends on US1 and US2 (adds progress to existing jobs). Can be implemented after US1/US2 are functional. +- **US3 (P2)**: Depends on Foundational (T008, T010). Fully independent. +- **US4 (P2)**: Depends on Foundational (T009, T010). Fully independent. +- **Additional (P2)**: Depends on Foundational (T010). Fully independent. + +### Parallel Opportunities Within Phases + +**Setup (Phase 1)**: +- T003, T004 can run in parallel (different files) + +**Foundational (Phase 2)**: +- T008, T009, T012 can run in parallel (different files) +- T007, T010, T011 must be sequential (modify same services) + +**User Story 1 Tests (T014-T017)**: All parallel (different test files) + +**User Story 1 Implementation**: +- T018 parallel with T019-T021 (different files initially) +- T019-T021 sequential (same file edits) +- T022-T025 sequential (testing/validation) + +**User Story 2 Tests (T026-T028)**: All parallel + +**User Story 2 Implementation**: +- T029 parallel with T030-T031 (different files) +- T030-T035 sequential (same file, progressive features) + +**User Story 3-4 and Additional**: Can all run in parallel after Foundational complete (different resources, no overlap) + +### Critical Path (Fastest Route to MVP) + +``` +T001 → T002 → T005 (Setup migrations, run) + ↓ +T007 → T010 → T011 (Foundational: Policy scope, AuditLogger, SyncJob) + ↓ +T014-T017 (US1 tests) → T018 → T019 → T020 (US1 core) → T024 (verify) + ↓ +MVP Ready: Bulk delete policies with type-to-confirm +``` + +**Estimated MVP Time**: ~12-16 hours (Setup + Foundational + US1) + +--- + +## Parallel Example: User Story 1 Implementation + +If working with a team, these tasks can run concurrently: + +**Developer A**: +```bash +# Write tests first (TDD) +tests/Unit/BulkPolicyDeleteJobTest.php (T014) +tests/Feature/BulkDeletePoliciesTest.php (T015) +``` + +**Developer B** (after foundational complete): +```bash +# Implement job +app/Jobs/BulkPolicyDeleteJob.php (T018) +``` + +**Developer C** (after T018 complete): +```bash +# Wire up Filament UI +app/Filament/Resources/PolicyResource.php (T019-T021) +``` + +**Developer A** (after implementation complete): +```bash +# Run tests, verify passing +./vendor/bin/sail artisan test tests/Feature/BulkDeletePoliciesTest.php (T024) +``` + +--- + +## Parallel Example: Multiple User Stories + +After Foundational phase completes, these can proceed in parallel (different team members): + +**Team Member 1**: US1 Bulk Delete Policies (T014-T025) +**Team Member 2**: US2 Bulk Export (T026-T035) +**Team Member 3**: US3 Prune Versions (T055-T066) +**Team Member 4**: US6 Progress Tracking (T043-T054) + +All integrate at the end without conflicts (different resources/files). + +--- + +## Testing Strategy + +### Unit Tests (tests/Unit/) +- BulkPolicyDeleteJob +- BulkPolicyExportJob +- BulkPolicyVersionPruneJob +- BulkBackupSetDeleteJob +- BulkRestoreRunDeleteJob +- BulkActionPermission +- PolicyVersion::pruneEligible scope +- RestoreRun::deletable scope +- BulkOperationRun progress helpers +- Circuit breaker logic + +### Feature Tests (tests/Feature/) +- BulkDeletePolicies (sync, async) +- BulkExportToBackup +- BulkTypeToConfirm +- BulkProgressNotification +- BulkPruneVersions (eligibility, skip reasons) +- BulkDeleteRestoreRuns (mixed statuses) +- BulkDeleteBackupSets (cascade) + +### Manual QA Scenarios (from quickstart.md) +1. Bulk delete 10 policies (sync, no type-to-confirm) +2. Bulk delete 25 policies (async, type-to-confirm) +3. Bulk export 30 policies (progress tracking) +4. Bulk prune 30 versions (eligibility checks) +5. Circuit breaker with mock failures (>50% fail) + +--- + +## Implementation Strategy + +### MVP First (P1 Features Only) + +**Goal**: Ship minimal viable feature to get user feedback + +**Scope**: +- Setup (T001-T006) +- Foundational (T007-T013) +- US1: Bulk Delete Policies (T014-T025) +- US2: Bulk Export (T026-T035) +- US5: Type-to-Confirm (T036-T042) - already in US1 + +**Estimated Time**: 16-22 hours +**Deliverable**: Admins can bulk delete and export policies with safety gates + +### Iteration 2 (P2 Features) + +**Goal**: Add progress tracking and additional resources + +**Scope**: +- US6: Progress Tracking (T043-T054) +- US3: Prune Versions (T055-T066) +- US4: Delete Runs (T067-T077) +- Additional: Delete Sets (T078-T085) + +**Estimated Time**: 10-12 hours +**Deliverable**: Full feature set with progress UI and all resources + +### Iteration 3 (Polish) + +**Goal**: Production readiness + +**Scope**: +- Polish (T086-T098) +- Performance testing +- Security audit +- Documentation finalization + +**Estimated Time**: 4-6 hours +**Deliverable**: Production-ready feature + +--- + +## Success Criteria + +- ✅ All P1 tests passing (US1, US2, US5) +- ✅ Bulk delete 100 policies in <2 minutes +- ✅ Type-to-confirm prevents accidents (≥20 items) +- ✅ Progress updates every 5-10s for queued jobs +- ✅ Audit log captures per-item outcomes +- ✅ Circuit breaker aborts at >50% failure rate +- ✅ Tenant isolation enforced (verified via tests) +- ✅ Manual QA checklist complete (5 scenarios) + +--- + +## Total Task Count + +- **Setup**: 6 tasks (T001-T006) +- **Foundational**: 7 tasks (T007-T013) +- **US1**: 12 tasks (T014-T025) +- **US2**: 10 tasks (T026-T035) +- **US5**: 7 tasks (T036-T042) +- **US6**: 12 tasks (T043-T054) +- **US3**: 12 tasks (T055-T066) +- **US4**: 11 tasks (T067-T077) +- **Additional**: 8 tasks (T078-T085) +- **Polish**: 13 tasks (T086-T098) + +**Total**: 98 tasks + +**MVP Scope**: 35 tasks (Setup + Foundational + US1 + US2 + US5) +**Full P1/P2 Scope**: 85 tasks (all user stories) +**Production Ready**: 98 tasks (including polish) + +--- + +**Status**: Tasks Ready for Implementation +**Next Step**: Begin Phase 1 (Setup) → Run migrations → Start US1 tests diff --git a/specs/005-policy-lifecycle/spec.md b/specs/005-policy-lifecycle/spec.md new file mode 100644 index 0000000..873c1dc --- /dev/null +++ b/specs/005-policy-lifecycle/spec.md @@ -0,0 +1,228 @@ +# Feature 005: Policy Lifecycle Management + +## Overview +Implement proper lifecycle management for policies that are deleted in Intune, including soft delete, UI indicators, and orphaned policy handling. + +## Problem Statement +Currently, when a policy is deleted in Intune: +- ❌ Policy remains in TenantAtlas database indefinitely +- ❌ No indication that policy no longer exists in Intune +- ❌ Backup Items reference "ghost" policies +- ❌ Users cannot distinguish between active and deleted policies + +**Discovered during**: Feature 004 manual testing (user deleted policy in Intune) + +## Goals +- **Primary**: Implement soft delete for policies removed from Intune +- **Secondary**: Show clear UI indicators for deleted policies +- **Tertiary**: Maintain referential integrity for Backup Items and Policy Versions + +## Scope +- **Policy Sync**: Detect missing policies during `SyncPoliciesJob` +- **Data Model**: Add `deleted_at`, `deleted_by` columns (Laravel Soft Delete pattern) +- **UI**: Badge indicators, filters, restore capability +- **Audit**: Log when policies are soft-deleted and restored + +--- + +## User Stories + +### User Story 1 - Automatic Soft Delete on Sync + +**As a system administrator**, I want policies deleted in Intune to be automatically marked as deleted in TenantAtlas, so that the inventory reflects the current Intune state. + +**Acceptance Criteria:** +1. **Given** a policy exists in TenantAtlas with `external_id` "abc-123", + **When** the next policy sync runs and "abc-123" is NOT returned by Graph API, + **Then** the policy is soft-deleted (sets `deleted_at = now()`) + +2. **Given** a soft-deleted policy, + **When** it re-appears in Intune (same `external_id`), + **Then** the policy is automatically restored (`deleted_at = null`) + +3. **Given** multiple policies are deleted in Intune, + **When** sync runs, + **Then** all missing policies are soft-deleted in a single transaction + +--- + +### User Story 2 - UI Indicators for Deleted Policies + +**As an admin**, I want to see clear indicators when viewing deleted policies, so I understand their status. + +**Acceptance Criteria:** +1. **Given** I view a Backup Item referencing a deleted policy, + **When** I see the policy name, + **Then** it shows a red "Deleted" badge next to the name + +2. **Given** I view the Policies list, + **When** I enable the "Show Deleted" filter, + **Then** deleted policies appear with: + - Red "Deleted" badge + - Deleted date in "Last Synced" column + - Grayed-out appearance + +3. **Given** a policy was deleted, + **When** I view the Policy detail page, + **Then** I see: + - Warning banner: "This policy was deleted from Intune on {date}" + - All data remains readable (versions, snapshots, metadata) + +--- + +### User Story 3 - Restore Workflow + +**As an admin**, I want to restore a deleted policy from backup, so I can recover accidentally deleted configurations. + +**Acceptance Criteria:** +1. **Given** I view a deleted policy's detail page, + **When** I click the "Restore to Intune" action, + **Then** the restore wizard opens pre-filled with the latest policy snapshot + +2. **Given** a policy is successfully restored to Intune, + **When** the next sync runs, + **Then** the policy is automatically undeleted in TenantAtlas (`deleted_at = null`) + +--- + +## Functional Requirements + +### Data Model + +**FR-005.1**: Policies table MUST use Laravel Soft Delete pattern: +```php +Schema::table('policies', function (Blueprint $table) { + $table->softDeletes(); // deleted_at + $table->string('deleted_by')->nullable(); // admin email who triggered deletion +}); +``` + +**FR-005.2**: Policy model MUST use `SoftDeletes` trait: +```php +use Illuminate\Database\Eloquent\SoftDeletes; + +class Policy extends Model { + use SoftDeletes; +} +``` + +### Policy Sync Behavior + +**FR-005.3**: `PolicySyncService::syncPolicies()` MUST detect missing policies: +- Collect all `external_id` values returned by Graph API +- Query existing policies for this tenant: `whereNotIn('external_id', $currentExternalIds)` +- Soft delete missing policies: `each(fn($p) => $p->delete())` + +**FR-005.4**: System MUST restore policies that re-appear: +- Check if policy exists with `Policy::withTrashed()->where('external_id', $id)->first()` +- If soft-deleted: call `$policy->restore()` +- Update `last_synced_at` timestamp + +**FR-005.5**: System MUST log audit entries: +- `policy.deleted` (when soft-deleted during sync) +- `policy.restored` (when re-appears in Intune) + +### UI Display + +**FR-005.6**: PolicyResource table MUST: +- Default query: exclude soft-deleted policies +- Add filter "Show Deleted" (includes `withTrashed()` in query) +- Show "Deleted" badge for soft-deleted policies + +**FR-005.7**: BackupItemsRelationManager MUST: +- Show "Deleted" badge when `policy->trashed()` returns true +- Allow viewing deleted policy details (read-only) + +**FR-005.8**: Policy detail view MUST: +- Show warning banner when policy is soft-deleted +- Display deletion date and reason (if available) +- Disable edit actions (policy no longer exists in Intune) + +--- + +## Non-Functional Requirements + +**NFR-005.1**: Soft delete MUST NOT break existing features: +- Backup Items keep valid foreign keys +- Policy Versions remain accessible +- Restore functionality works for deleted policies + +**NFR-005.2**: Performance: Sync detection MUST NOT cause N+1 queries: +- Use single `whereNotIn()` query to find missing policies +- Batch soft-delete operation + +**NFR-005.3**: Data retention: Soft-deleted policies MUST be retained for audit purposes (no automatic purging) + +--- + +## Implementation Plan + +### Phase 1: Data Model (30 min) +1. Create migration for `policies` soft delete columns +2. Add `SoftDeletes` trait to Policy model +3. Run migration on dev environment + +### Phase 2: Sync Logic (1 hour) +1. Update `PolicySyncService::syncPolicies()` + - Track current external IDs from Graph + - Soft delete missing policies + - Restore re-appeared policies +2. Add audit logging +3. Test with manual deletion in Intune + +### Phase 3: UI Indicators (1.5 hours) +1. Update `PolicyResource`: + - Add "Show Deleted" filter + - Add "Deleted" badge column + - Modify query to exclude deleted by default +2. Update `BackupItemsRelationManager`: + - Show "Deleted" badge for `policy->trashed()` +3. Update Policy detail view: + - Warning banner for deleted policies + - Disable edit actions + +### Phase 4: Testing (1 hour) +1. Unit tests: + - Test soft delete on sync + - Test restore on re-appearance +2. Feature tests: + - E2E sync with deleted policies + - UI filter behavior +3. Manual QA: + - Delete policy in Intune → sync → verify soft delete + - Re-create policy → sync → verify restore + +**Total Estimated Duration**: ~4-5 hours + +--- + +## Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| Foreign key constraints block soft delete | Laravel soft delete only sets timestamp, constraints remain valid | +| Bulk delete impacts performance | Use chunked queries if tenant has 1000+ policies | +| Deleted policies clutter UI | Default filter hides them, "Show Deleted" is opt-in | + +--- + +## Success Criteria +1. ✅ Policies deleted in Intune are soft-deleted in TenantAtlas within 1 sync cycle +2. ✅ Re-appearing policies are automatically restored +3. ✅ UI clearly indicates deleted status +4. ✅ Backup Items and Versions remain accessible for deleted policies +5. ✅ No breaking changes to existing features + +--- + +## Related Features +- Feature 004: Assignments & Scope Tags (discovered this issue during testing) +- Feature 001: Backup/Restore (must work with deleted policies) + +--- + +**Status**: Planned (Post-Feature 004) +**Priority**: P2 (Quality of Life improvement) +**Created**: 2025-12-22 +**Author**: AI + Ahmed +**Next Steps**: Implement after Feature 004 Phase 3 testing complete diff --git a/specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md b/specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md deleted file mode 100644 index ce52f7d..0000000 --- a/specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md +++ /dev/null @@ -1,469 +0,0 @@ -# Feature 185: Implementation Status Report - -## Executive Summary - -**Status**: ✅ **Core Implementation Complete** (Phases 1-5) -**Date**: 2025-12-13 -**Remaining Work**: Testing & Manual Verification (Phases 6-7) - -## Implementation Progress - -### ✅ Completed Phases (1-5) - -#### Phase 1: Database Foundation -- ✅ T001: Migration created and applied successfully (73.61ms) -- ✅ T002: SettingsCatalogDefinition model with helper methods -- **Result**: `settings_catalog_definitions` table exists with GIN index on JSONB - -#### Phase 2: Definition Resolver Service -- ✅ T003-T007: Complete SettingsCatalogDefinitionResolver service -- **Features**: - - 3-tier caching: Memory → Database (30 days) → Graph API - - Batch resolution with `$filter=id in (...)` optimization - - Non-blocking cache warming with error handling - - Graceful fallback with prettified definition IDs -- **File**: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` (267 lines) - -#### Phase 3: Snapshot Enrichment -- ✅ T008-T010: Extended PolicySnapshotService -- **Features**: - - Extracts definition IDs from settings (including nested children) - - Calls warmCache() after settings hydration - - Adds metadata: `definition_count`, `definitions_cached` -- **File**: `app/Services/Intune/PolicySnapshotService.php` (extended) - -#### Phase 4: Normalizer Enhancement -- ✅ T011-T014: Extended PolicyNormalizer -- **Features**: - - `normalizeSettingsCatalogGrouped()` main method - - Value formatting: bool → badges, int → formatted, string → truncated - - Grouping by categoryId with fallback to definition ID segments - - Recursive flattening of nested group settings - - Alphabetical sorting of groups -- **File**: `app/Services/Intune/PolicyNormalizer.php` (extended with 8 new methods) - -#### Phase 5: UI Implementation -- ✅ T015-T022: Complete Settings tab with grouped accordion view -- **Features**: - - Filament Section components for collapsible groups - - First group expanded by default - - Setting rows with labels, formatted values, help text - - Alpine.js copy buttons with clipboard API - - Client-side search filtering - - Empty states and fallback warnings - - Dark mode support -- **Files**: - - `resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php` (~130 lines) - - `app/Filament/Resources/PolicyResource.php` (Settings tab extended) - -### ⏳ Pending Phases (6-7) - -#### Phase 6: Manual Verification (T023-T025) -- [ ] T023: Verify JSON tab still works -- [ ] T024: Verify fallback message for uncached definitions -- [ ] T025: Ensure JSON viewer scoped to Policy View only - -**Estimated Time**: ~15 minutes -**Action Required**: Navigate to `/admin/policies/{id}` for Settings Catalog policy - -#### Phase 7: Testing & Validation (T026-T042) -- [ ] T026-T031: Unit tests (SettingsCatalogDefinitionResolverTest, PolicyNormalizerSettingsCatalogTest) -- [ ] T032-T037: Feature tests (PolicyViewSettingsCatalogReadableTest) -- [ ] T038-T039: Pest suite execution, Pint formatting -- [ ] T040-T042: Git review, migration check, manual QA walkthrough - -**Estimated Time**: ~4-5 hours -**Action Required**: Write comprehensive test coverage - ---- - -## Code Quality Verification - -### ✅ Laravel Pint -- **Status**: PASS - 32 files formatted -- **Command**: `./vendor/bin/sail pint --dirty` -- **Result**: All code compliant with Laravel coding standards - -### ✅ Cache Management -- **Command**: `./vendor/bin/sail artisan optimize:clear` -- **Result**: All caches cleared (config, views, routes, Blade, Filament) - -### ✅ Database Migration -- **Command**: `./vendor/bin/sail artisan migrate` -- **Result**: `settings_catalog_definitions` table exists -- **Verification**: `Schema::hasTable('settings_catalog_definitions')` returns `true` - ---- - -## Architecture Overview - -### Service Layer - -``` -PolicySnapshotService - ↓ (extracts definition IDs) -SettingsCatalogDefinitionResolver - ↓ (resolves definitions) -PolicyNormalizer - ↓ (groups & formats) -PolicyResource (Filament) - ↓ (renders) -settings-catalog-grouped.blade.php -``` - -### Caching Strategy - -``` -Request - ↓ -Memory Cache (Laravel Cache, request-level) - ↓ (miss) -Database Cache (30 days TTL) - ↓ (miss) -Graph API (/deviceManagement/configurationSettings) - ↓ (store) -Database + Memory - ↓ (fallback on Graph failure) -Prettified Definition ID -``` - -### UI Flow - -``` -Policy View (Filament) - ↓ -Tabs: Settings | JSON - ↓ (Settings tab) -Check metadata.definitions_cached - ↓ (true) -settings_grouped ViewEntry - ↓ -normalizeSettingsCatalogGrouped() - ↓ -Blade Component - ↓ -Accordion Groups (Filament Sections) - ↓ -Setting Rows (label, value, help text, copy button) -``` - ---- - -## Files Created/Modified - -### Created Files (5) - -1. **database/migrations/2025_12_13_212126_create_settings_catalog_definitions_table.php** - - Purpose: Cache setting definitions from Graph API - - Schema: 9 columns + timestamps, GIN index on JSONB - - Status: ✅ Applied (73.61ms) - -2. **app/Models/SettingsCatalogDefinition.php** - - Purpose: Eloquent model for cached definitions - - Methods: `findByDefinitionId()`, `findByDefinitionIds()` - - Status: ✅ Complete - -3. **app/Services/Intune/SettingsCatalogDefinitionResolver.php** - - Purpose: Fetch and cache definitions with 3-tier strategy - - Lines: 267 - - Methods: `resolve()`, `resolveOne()`, `warmCache()`, `clearCache()`, `prettifyDefinitionId()` - - Status: ✅ Complete with error handling - -4. **resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php** - - Purpose: Blade template for grouped settings accordion - - Lines: ~130 - - Features: Alpine.js interactivity, Filament Sections, search filtering - - Status: ✅ Complete with dark mode support - -5. **specs/185-settings-catalog-readable/** (Directory with 3 files) - - `spec.md` - Complete feature specification - - `plan.md` - Implementation plan - - `tasks.md` - 42 tasks with FR traceability - - Status: ✅ Complete with implementation notes - -### Modified Files (3) - -1. **app/Services/Intune/PolicySnapshotService.php** - - Changes: Added `SettingsCatalogDefinitionResolver` injection - - New method: `extractDefinitionIds()` (recursive extraction) - - Extended method: `hydrateSettingsCatalog()` (cache warming + metadata) - - Status: ✅ Extended without breaking existing functionality - -2. **app/Services/Intune/PolicyNormalizer.php** - - Changes: Added `SettingsCatalogDefinitionResolver` injection - - New methods: 8 methods (~200 lines) - - `normalizeSettingsCatalogGrouped()` (main entry point) - - `extractAllDefinitionIds()`, `flattenSettingsCatalogForGrouping()` - - `formatSettingsCatalogValue()`, `groupSettingsByCategory()` - - `extractCategoryFromDefinitionId()`, `formatCategoryTitle()` - - Status: ✅ Extended with comprehensive formatting/grouping logic - -3. **app/Filament/Resources/PolicyResource.php** - - Changes: Extended Settings tab in `policy_content` Tabs - - New entries: - - `settings_grouped` ViewEntry (uses Blade component) - - `definitions_not_cached` TextEntry (fallback message) - - Conditional rendering: Grouped view only if `definitions_cached === true` - - Status: ✅ Extended Settings tab, JSON tab preserved - ---- - -## Verification Checklist (Pre-Testing) - -### ✅ Code Quality -- [X] Laravel Pint passed (32 files) -- [X] All code formatted with PSR-12 conventions -- [X] No Pint warnings or errors - -### ✅ Database -- [X] Migration applied successfully -- [X] Table exists with correct schema -- [X] Indexes created (definition_id unique, category_id, GIN on raw) - -### ✅ Service Injection -- [X] SettingsCatalogDefinitionResolver registered in service container -- [X] PolicySnapshotService constructor updated -- [X] PolicyNormalizer constructor updated -- [X] Laravel auto-resolves dependencies - -### ✅ Caching -- [X] All caches cleared (config, views, routes, Blade, Filament) -- [X] Blade component compiled -- [X] Filament schema cache refreshed - -### ✅ UI Integration -- [X] Settings tab extended with grouped view -- [X] JSON tab preserved from Feature 002 -- [X] Conditional rendering based on metadata -- [X] Fallback message implemented - -### ⏳ Manual Verification Pending -- [ ] Navigate to Policy View for Settings Catalog policy -- [ ] Verify accordion renders with groups -- [ ] Verify display names shown (not raw definition IDs) -- [ ] Verify values formatted (badges, numbers, truncated strings) -- [ ] Test search filtering -- [ ] Test copy buttons -- [ ] Switch to JSON tab, verify snapshot renders -- [ ] Test fallback for policy without cached definitions -- [ ] Test dark mode toggle - -### ⏳ Testing Pending -- [ ] Unit tests written and passing -- [ ] Feature tests written and passing -- [ ] Performance benchmarks validated - ---- - -## Next Steps (Priority Order) - -### Immediate (Phase 6 - Manual Verification) - -1. **Open Policy View** (5 min) - - Navigate to `/admin/policies/{id}` for Settings Catalog policy - - Verify page loads without errors - - Check browser console for JavaScript errors - -2. **Verify Tabs & Accordion** (5 min) - - Confirm "Settings" and "JSON" tabs visible - - Click Settings tab, verify accordion renders - - Verify groups collapsible (first expanded by default) - - Click JSON tab, verify snapshot renders with copy button - -3. **Verify Display & Formatting** (5 min) - - Check setting labels show display names (not `device_vendor_msft_...`) - - Verify bool values show as "Enabled"/"Disabled" badges (green/gray) - - Verify int values formatted with separators (e.g., "1,000") - - Verify long strings truncated with "..." and copy button - -4. **Test Search & Fallback** (5 min) - - Type in search box (if visible), verify filtering works - - Test copy buttons (long values) - - Find policy WITHOUT cached definitions - - Verify fallback message: "Definitions not yet cached..." - - Verify JSON tab still accessible - -**Total Estimated Time**: ~20 minutes - -### Short-Term (Phase 7 - Unit Tests) - -1. **Create Unit Tests** (2-3 hours) - - `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - - Test `resolve()` with batch IDs - - Test memory cache hit - - Test database cache hit - - Test Graph API fetch - - Test fallback prettification - - Test non-blocking warmCache() - - `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - - Test `normalizeSettingsCatalogGrouped()` output structure - - Test value formatting (bool, int, string, choice) - - Test grouping by categoryId - - Test fallback grouping by definition ID segments - - Test recursive definition ID extraction - -2. **Create Feature Tests** (2 hours) - - `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - - Test Settings Catalog policy view shows tabs - - Test Settings tab shows display names (not definition IDs) - - Test values formatted correctly (badges, numbers, truncation) - - Test search filters settings - - Test fallback message when definitions not cached - - Test JSON tab still accessible - -3. **Run Test Suite** (15 min) - - `./vendor/bin/sail artisan test --filter=SettingsCatalog` - - Fix any failures - - Verify all tests pass - -**Total Estimated Time**: ~5 hours - -### Medium-Term (Performance & Polish) - -1. **Performance Testing** (1 hour) - - Create test policy with 200+ settings - - Measure render time (target: <2s) - - Measure definition resolution time (target: <500ms for 50 cached) - - Profile with Laravel Telescope or Debugbar - -2. **Manual QA Walkthrough** (1 hour) - - Test all user stories (US-UI-04, US-UI-05, US-UI-06) - - Verify all success criteria (SC-001 to SC-010) - - Test dark mode toggle - - Test with different policy types - - Document any issues or enhancements - -**Total Estimated Time**: ~2 hours - ---- - -## Risk Assessment - -### ✅ Mitigated Risks - -- **Graph API Rate Limiting**: Non-blocking cache warming prevents snapshot save failures -- **Definition Schema Changes**: Raw JSONB storage allows future parsing updates -- **Large Policy Rendering**: Accordion lazy-loading via Filament Sections -- **Missing Definitions**: Multi-layer fallback (prettified IDs → warning badges → info messages) - -### ⚠️ Outstanding Risks - -- **Performance with 500+ Settings**: Not tested yet (Phase 7, T042) -- **Graph API Downtime**: Cache helps, but first sync may fail (acceptable trade-off) -- **Browser Compatibility**: Alpine.js clipboard API requires HTTPS (Dokploy provides SSL) - -### ℹ️ Known Limitations - -- **Search**: Client-side only (Blade-level filtering), no debouncing for large policies -- **Value Expansion**: Long strings truncated, no inline expansion (copy button only) -- **Nested Groups**: Flattened in UI, hierarchy not visually preserved - ---- - -## Constitution Compliance - -### ✅ Safety-First -- Read-only feature, no edit capabilities -- Graceful degradation at every layer -- Non-blocking operations (warmCache) - -### ✅ Immutable Versioning -- Snapshot enrichment adds metadata only -- No modification of existing snapshot data -- Definition cache separate from policy snapshots - -### ✅ Defensive Restore -- Not applicable (read-only feature) - -### ✅ Auditability -- Raw JSON still accessible via JSON tab -- Definition resolution logged via Laravel Log -- Graph API calls auditable via GraphLogger - -### ✅ Tenant-Aware -- Resolver respects tenant scoping via GraphClient -- Definitions scoped per tenant (via Graph API calls) - -### ✅ Graph Abstraction -- Uses existing GraphClientInterface (no direct MS Graph SDK calls) -- Follows existing abstraction patterns - -### ✅ Spec-Driven -- Full spec + plan + tasks before implementation -- FR→Task traceability maintained -- Implementation notes added to tasks.md - ---- - -## Deployment Readiness - -### ✅ Local Development (Laravel Sail) -- [X] Database migration applied -- [X] Services registered in container -- [X] Caches cleared -- [X] Code formatted with Pint -- [X] Table exists with data ready for seeding - -### ⏳ Staging Deployment (Dokploy) -- [ ] Run migrations: `php artisan migrate` -- [ ] Clear caches: `php artisan optimize:clear` -- [ ] Verify environment variables (none required for Feature 185) -- [ ] Test with real Intune tenant data -- [ ] Monitor Graph API rate limits - -### ⏳ Production Deployment (Dokploy) -- [ ] Complete staging validation -- [ ] Feature flag enabled (if applicable) -- [ ] Monitor performance metrics -- [ ] Document rollback plan (drop table, revert code) - ---- - -## Support Information - -### Troubleshooting Guide - -**Issue**: Settings tab shows raw definition IDs instead of display names -- **Cause**: Definitions not cached yet -- **Solution**: Wait for next policy sync (SyncPoliciesJob) or manually trigger sync - -**Issue**: Accordion doesn't render, blank Settings tab -- **Cause**: JavaScript error in Blade component -- **Solution**: Check browser console for errors, verify Alpine.js loaded - -**Issue**: "Definitions not cached" message persists -- **Cause**: Graph API call failed during snapshot -- **Solution**: Check logs for Graph API errors, verify permissions for `/deviceManagement/configurationSettings` endpoint - -**Issue**: Performance slow with large policies -- **Cause**: Too many settings rendered at once -- **Solution**: Consider pagination or virtual scrolling (future enhancement) - -### Maintenance Tasks - -- **Cache Clearing**: Run `php artisan cache:clear` if definitions stale -- **Database Cleanup**: Run `SettingsCatalogDefinition::where('updated_at', '<', now()->subDays(30))->delete()` to prune old definitions -- **Performance Monitoring**: Watch `policy_view` page load times in Telescope - ---- - -## Conclusion - -**Implementation Status**: ✅ **CORE COMPLETE** - -Phases 1-5 implemented successfully with: -- ✅ Database schema + model -- ✅ Definition resolver with 3-tier caching -- ✅ Snapshot enrichment with cache warming -- ✅ Normalizer with grouping/formatting -- ✅ UI with accordion, search, and fallback - -**Next Action**: **Phase 6 Manual Verification** (~20 min) - -Navigate to Policy View and verify all features work as expected before proceeding to Phase 7 testing. - -**Estimated Remaining Work**: ~7 hours -- Phase 6: ~20 min -- Phase 7: ~5-7 hours (tests + QA) - -**Feature Delivery Target**: Ready for staging deployment after Phase 7 completion. diff --git a/specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md b/specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md deleted file mode 100644 index c43dda0..0000000 --- a/specs/185-settings-catalog-readable/MANUAL_VERIFICATION_GUIDE.md +++ /dev/null @@ -1,312 +0,0 @@ -# Feature 185: Manual Verification Guide (Phase 6) - -## Quick Start - -**Estimated Time**: 20 minutes -**Prerequisites**: Settings Catalog policy exists in database with snapshot - ---- - -## Verification Steps - -### Step 1: Navigate to Policy View (2 min) - -1. Open browser: `http://localhost` (or your Sail URL) -2. Login to Filament admin panel -3. Navigate to **Policies** resource -4. Click on a **Settings Catalog** policy (look for `settingsCatalogPolicy` type) - -**Expected Result**: -- ✅ Page loads without errors -- ✅ Policy details visible -- ✅ No browser console errors - -**If it fails**: -- Check browser console for JavaScript errors -- Run `./vendor/bin/sail artisan optimize:clear` -- Verify policy has `versions` relationship loaded - ---- - -### Step 2: Verify Tabs Present (2 min) - -**Action**: Look at the Policy View infolist - -**Expected Result**: -- ✅ "Settings" tab visible -- ✅ "JSON" tab visible -- ✅ Settings tab is default (active) - -**If tabs missing**: -- Check if policy is actually Settings Catalog type -- Verify PolicyResource.php has Tabs component for `policy_content` -- Check Feature 002 JSON viewer implementation - ---- - -### Step 3: Verify Settings Tab - Accordion (5 min) - -**Action**: Click on "Settings" tab (if not already active) - -**Expected Result**: -- ✅ Accordion groups render -- ✅ Each group has: - - Title (e.g., "Device Vendor Msft", "Biometric Authentication") - - Description (if available) - - Setting count badge (e.g., "12 settings") -- ✅ First group expanded by default -- ✅ Other groups collapsed -- ✅ Click group header toggles collapse/expand - -**If accordion missing**: -- Check if `metadata.definitions_cached === true` in snapshot -- Verify normalizer returns groups structure -- Check Blade component exists: `settings-catalog-grouped.blade.php` - ---- - -### Step 4: Verify Display Names (Not Definition IDs) (3 min) - -**Action**: Expand a group and look at setting labels - -**Expected Result**: -- ✅ Labels show human-readable names: - - ✅ "Biometric Authentication" (NOT `device_vendor_msft_policy_biometric_authentication`) - - ✅ "Password Minimum Length" (NOT `device_vendor_msft_policy_password_minlength`) -- ✅ No `device_vendor_msft_...` visible in labels - -**If definition IDs visible**: -- Check if definitions cached in database: `SettingsCatalogDefinition::count()` -- Run policy sync manually to trigger cache warming -- Verify fallback message visible: "Definitions not yet cached..." - ---- - -### Step 5: Verify Value Formatting (5 min) - -**Action**: Look at setting values in different groups - -**Expected Result**: -- ✅ **Boolean values**: Badges with "Enabled" (green) or "Disabled" (gray) -- ✅ **Integer values**: Formatted with separators (e.g., "1,000" not "1000") -- ✅ **String values**: Truncated if >100 chars with "..." -- ✅ **Choice values**: Show choice label (not raw ID) - -**If formatting incorrect**: -- Check `formatSettingsCatalogValue()` method in PolicyNormalizer -- Verify Blade component conditionals for value types -- Inspect browser to see actual rendered HTML - ---- - -### Step 6: Test Copy Buttons (2 min) - -**Action**: Find a setting with a long value, click copy button - -**Expected Result**: -- ✅ Copy button visible for long values -- ✅ Click copy button → clipboard receives value -- ✅ Button shows checkmark for 2 seconds -- ✅ Button returns to copy icon after timeout - -**If copy button missing/broken**: -- Check Alpine.js loaded (inspect page source for `@livewireScripts`) -- Verify clipboard API available (requires HTTPS or localhost) -- Check browser console for JavaScript errors - ---- - -### Step 7: Test Search Filtering (Optional - if search visible) (2 min) - -**Action**: Type in search box (if visible at top of Settings tab) - -**Expected Result**: -- ✅ Search box visible with placeholder "Search settings..." -- ✅ Type search query (e.g., "biometric") -- ✅ Only matching settings shown -- ✅ Non-matching groups hidden/empty -- ✅ Clear search resets view - -**If search not visible**: -- This is expected for MVP (Blade-level implementation, no dedicated input yet) -- Search logic exists in Blade template but may need Livewire wiring - ---- - -### Step 8: Verify JSON Tab (2 min) - -**Action**: Click "JSON" tab - -**Expected Result**: -- ✅ Tab switches to JSON view -- ✅ Snapshot renders with syntax highlighting -- ✅ Copy button visible at top -- ✅ Click copy button → full JSON copied to clipboard -- ✅ Can switch back to Settings tab - -**If JSON tab broken**: -- Verify Feature 002 implementation still intact -- Check `pepperfm/filament-json` package installed -- Verify PolicyResource.php has JSON ViewEntry - ---- - -### Step 9: Test Fallback Message (3 min) - -**Action**: Find a Settings Catalog policy WITHOUT cached definitions (or manually delete definitions from database) - -**Steps to test**: -1. Run: `./vendor/bin/sail artisan tinker` -2. Execute: `\App\Models\SettingsCatalogDefinition::truncate();` -3. Navigate to Policy View for Settings Catalog policy -4. Click Settings tab - -**Expected Result**: -- ✅ Settings tab shows fallback message: - - "Definitions not yet cached. Settings will be shown with raw IDs." - - Helper text: "Switch to JSON tab or wait for next sync" -- ✅ JSON tab still accessible -- ✅ No error messages or broken layout - -**If fallback not visible**: -- Check conditional rendering in PolicyResource.php -- Verify `metadata.definitions_cached` correctly set in snapshot -- Check Blade component has fallback TextEntry - ---- - -### Step 10: Test Dark Mode (Optional) (2 min) - -**Action**: Toggle Filament dark mode (if available) - -**Expected Result**: -- ✅ Accordion groups adjust colors -- ✅ Badges adjust colors (dark mode variants) -- ✅ Text remains readable -- ✅ No layout shifts or broken styles - -**If dark mode broken**: -- Check Blade component uses `dark:` Tailwind classes -- Verify Filament Section components support dark mode -- Inspect browser to see actual computed styles - ---- - -## Success Criteria Checklist - -After completing all steps, mark these off: - -- [ ] **T023**: JSON tab works (from Feature 002) -- [ ] **T024**: Fallback message shows when definitions not cached -- [ ] **T025**: JSON viewer only renders on Policy View (not globally) - ---- - -## Common Issues & Solutions - -### Issue: "Definitions not yet cached" persists - -**Cause**: SyncPoliciesJob hasn't run yet or Graph API call failed - -**Solution**: -1. Manually trigger sync: - ```bash - ./vendor/bin/sail artisan tinker - ``` - ```php - $policy = \App\Models\Policy::first(); - \App\Jobs\SyncPoliciesJob::dispatch(); - ``` -2. Check logs for Graph API errors: - ```bash - ./vendor/bin/sail artisan log:show - ``` - -### Issue: Accordion doesn't render - -**Cause**: Blade component error or missing groups - -**Solution**: -1. Check browser console for errors -2. Verify normalizer output: - ```bash - ./vendor/bin/sail artisan tinker - ``` - ```php - $policy = \App\Models\Policy::first(); - $snapshot = $policy->versions()->orderByDesc('captured_at')->value('snapshot'); - $normalizer = app(\App\Services\Intune\PolicyNormalizer::class); - $groups = $normalizer->normalizeSettingsCatalogGrouped($snapshot['settings'] ?? []); - dd($groups); - ``` - -### Issue: Copy buttons don't work - -**Cause**: Alpine.js not loaded or clipboard API unavailable - -**Solution**: -1. Verify Alpine.js loaded: - - Open browser console - - Type `window.Alpine` → should return object -2. Check HTTPS or localhost (clipboard API requires secure context) -3. Fallback: Use "View JSON" tab and copy from there - ---- - -## Next Steps After Verification - -### If All Tests Pass ✅ - -Proceed to **Phase 7: Testing & Validation** - -1. Write unit tests (T026-T031) -2. Write feature tests (T032-T037) -3. Run Pest suite (T038-T039) -4. Manual QA walkthrough (T040-T042) - -**Estimated Time**: ~5-7 hours - -### If Issues Found ⚠️ - -1. Document issues in `specs/185-settings-catalog-readable/ISSUES.md` -2. Fix critical issues (broken UI, errors) -3. Re-run verification steps -4. Proceed to Phase 7 only after verification passes - ---- - -## Reporting Results - -After completing verification, update tasks.md: - -```bash -# Mark T023-T025 as complete -vim specs/185-settings-catalog-readable/tasks.md -``` - -Add implementation notes: -```markdown -- [X] **T023** Verify JSON tab still works - - **Implementation Note**: Verified tabs functional, JSON viewer renders snapshot - -- [X] **T024** Add fallback for policies without cached definitions - - **Implementation Note**: Fallback message shows info with guidance to JSON tab - -- [X] **T025** Ensure JSON viewer only renders on Policy View - - **Implementation Note**: Verified scoping correct, only shows on Policy resource -``` - ---- - -## Contact & Support - -If verification fails or you need assistance: - -1. Check logs: `./vendor/bin/sail artisan log:show` -2. Review implementation status: `specs/185-settings-catalog-readable/IMPLEMENTATION_STATUS.md` -3. Review code: `app/Services/Intune/`, `app/Filament/Resources/PolicyResource.php` -4. Ask for help with specific error messages and context - ---- - -**End of Manual Verification Guide** diff --git a/specs/185-settings-catalog-readable/plan.md b/specs/185-settings-catalog-readable/plan.md deleted file mode 100644 index e096851..0000000 --- a/specs/185-settings-catalog-readable/plan.md +++ /dev/null @@ -1,414 +0,0 @@ -# Feature 185: Implementation Plan - -## Tech Stack -- **Backend**: Laravel 12, PHP 8.4 -- **Database**: PostgreSQL (JSONB for raw definition storage) -- **Frontend**: Filament 4, Livewire 3, Tailwind CSS -- **Graph Client**: Existing `GraphClientInterface` -- **JSON Viewer**: `pepperfm/filament-json` (installed) - -## Architecture Overview - -### Services Layer -``` -app/Services/Intune/ -├── SettingsCatalogDefinitionResolver.php (NEW) -├── PolicyNormalizer.php (EXTEND) -└── PolicySnapshotService.php (EXTEND) -``` - -### Database Layer -``` -database/migrations/ -└── xxxx_create_settings_catalog_definitions_table.php (NEW) - -app/Models/ -└── SettingsCatalogDefinition.php (NEW) -``` - -### UI Layer -``` -app/Filament/Resources/ -├── PolicyResource.php (EXTEND - infolist with tabs) -└── PolicyVersionResource.php (FUTURE - optional) - -resources/views/filament/infolists/entries/ -└── settings-catalog-grouped.blade.php (NEW - accordion view) -``` - -## Component Responsibilities - -### 1. SettingsCatalogDefinitionResolver -**Purpose**: Fetch and cache setting definitions from Graph API - -**Key Methods**: -- `resolve(array $definitionIds): array` - Batch resolve definitions -- `resolveOne(string $definitionId): ?array` - Single definition lookup -- `warmCache(array $definitionIds): void` - Pre-populate cache -- `clearCache(?string $definitionId = null): void` - Cache invalidation - -**Dependencies**: -- `GraphClientInterface` - Graph API calls -- `SettingsCatalogDefinition` model - Database cache -- Laravel Cache - Memory-level cache - -**Caching Strategy**: -1. Check memory cache (request-level) -2. Check database cache (30-day TTL) -3. Fetch from Graph API -4. Store in DB + memory - -**Graph Endpoints**: -- `/deviceManagement/configurationSettings` (global catalog) -- `/deviceManagement/configurationPolicies/{id}/settings/{settingId}/settingDefinitions` (policy-specific) - -### 2. PolicyNormalizer (Extension) -**Purpose**: Transform Settings Catalog snapshot into UI-ready structure - -**New Method**: `normalizeSettingsCatalog(array $snapshot, array $definitions): array` - -**Output Structure**: -```php -[ - 'type' => 'settings_catalog', - 'groups' => [ - [ - 'title' => 'Windows Hello for Business', - 'description' => 'Configure biometric authentication settings', - 'settings' => [ - [ - 'label' => 'Use biometrics', - 'value_display' => 'Enabled', - 'value_raw' => true, - 'help_text' => 'Allow users to sign in with fingerprint...', - 'definition_id' => 'device_vendor_msft_passportforwork_biometrics_usebiometrics', - 'instance_type' => 'ChoiceSettingInstance' - ] - ] - ] - ] -] -``` - -**Value Formatting Rules**: -- `ChoiceSettingInstance`: Extract choice label from `@odata.type` or value -- `SimpleSetting` (bool): "Enabled" / "Disabled" -- `SimpleSetting` (int): Number formatted with separators -- `SimpleSetting` (string): Truncate >100 chars, add "..." -- `GroupSettingCollectionInstance`: Flatten children recursively - -**Grouping Strategy**: -- Group by `categoryId` from definition metadata -- Fallback: Group by first segment of definition ID (e.g., `device_vendor_msft_`) -- Sort groups alphabetically - -### 3. PolicySnapshotService (Extension) -**Purpose**: Enrich snapshots with definition metadata after hydration - -**Modified Flow**: -``` -1. Hydrate settings from Graph (existing) -2. Extract all settingDefinitionId + children (NEW) -3. Call SettingsCatalogDefinitionResolver::warmCache() (NEW) -4. Add metadata to snapshot: definitions_cached, definition_count (NEW) -5. Save snapshot (existing) -``` - -**Non-Blocking**: Definition resolution should not block policy sync -- Use try/catch for Graph API calls -- Mark `definitions_cached: false` on failure -- Continue with snapshot save - -### 4. PolicyResource (UI Extension) -**Purpose**: Render Settings Catalog policies with readable UI - -**Changes**: -1. Add Tabs component to infolist: - - "Settings" tab (default) - - "JSON" tab (existing Feature 002 implementation) - -2. Settings Tab Structure: - - Search/filter input (top) - - Accordion component (groups) - - Each group: Section with settings table - - Fallback: Show info message if no definitions cached - -3. JSON Tab: - - Existing implementation from Feature 002 - - Shows full snapshot with copy button - -**Conditional Rendering**: -- Show tabs ONLY for `settingsCatalogPolicy` type -- For other policy types: Keep existing simple sections - -## Database Schema - -### Table: `settings_catalog_definitions` -```sql -CREATE TABLE settings_catalog_definitions ( - id BIGSERIAL PRIMARY KEY, - definition_id VARCHAR(500) UNIQUE NOT NULL, - display_name VARCHAR(255) NOT NULL, - description TEXT, - help_text TEXT, - category_id VARCHAR(255), - ux_behavior VARCHAR(100), - raw JSONB NOT NULL, - created_at TIMESTAMP, - updated_at TIMESTAMP -); - -CREATE INDEX idx_definition_id ON settings_catalog_definitions(definition_id); -CREATE INDEX idx_category_id ON settings_catalog_definitions(category_id); -CREATE INDEX idx_raw_gin ON settings_catalog_definitions USING GIN(raw); -``` - -**Indexes**: -- `definition_id` - Primary lookup key -- `category_id` - Grouping queries -- `raw` (GIN) - JSONB queries if needed - -## Graph API Integration - -### Endpoints Used - -1. **Global Catalog** (Preferred): -``` -GET /deviceManagement/configurationSettings -GET /deviceManagement/configurationSettings/{settingDefinitionId} -``` - -2. **Policy-Specific** (Fallback): -``` -GET /deviceManagement/configurationPolicies/{policyId}/settings/{settingId}/settingDefinitions -``` - -### Request Optimization -- Batch requests where possible -- Use `$select` to limit fields -- Use `$filter` for targeted lookups -- Respect rate limits (429 retry logic) - -## UI/UX Flow - -### Policy View Page Flow -1. User navigates to `/admin/policies/{id}` -2. Policy details loaded (existing) -3. Check policy type: - - If `settingsCatalogPolicy`: Show tabs - - Else: Show existing sections -4. Default to "Settings" tab -5. Load normalized settings from PolicyNormalizer -6. Render accordion with groups -7. User can search/filter settings -8. User can switch to "JSON" tab for raw view - -### Settings Tab Layout -``` -┌─────────────────────────────────────────────┐ -│ [Search settings...] [🔍] │ -├─────────────────────────────────────────────┤ -│ ▼ Windows Hello for Business │ -│ ├─ Use biometrics: Enabled │ -│ ├─ Use facial recognition: Disabled │ -│ └─ PIN minimum length: 6 │ -├─────────────────────────────────────────────┤ -│ ▼ Device Lock Settings │ -│ ├─ Password expiration days: 90 │ -│ └─ Password history: 5 │ -└─────────────────────────────────────────────┘ -``` - -### JSON Tab Layout -``` -┌─────────────────────────────────────────────┐ -│ Full Policy Configuration [Copy] │ -├─────────────────────────────────────────────┤ -│ { │ -│ "@odata.type": "...", │ -│ "name": "WHFB Settings", │ -│ "settings": [...] │ -│ } │ -└─────────────────────────────────────────────┘ -``` - -## Error Handling - -### Definition Not Found -- **UI**: Show prettified definition ID -- **Label**: Convert `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name" -- **Icon**: Info icon with tooltip "Definition not cached" - -### Graph API Failure -- **During Sync**: Mark `definitions_cached: false`, continue -- **During View**: Show cached data or fallback labels -- **Log**: Record Graph API errors for debugging - -### Malformed Snapshot -- **Validation**: Check for required fields before normalization -- **Fallback**: Show raw JSON tab, hide Settings tab -- **Warning**: Display admin-friendly error message - -## Performance Considerations - -### Database Queries -- Eager load definitions for all settings in one query -- Use `whereIn()` for batch lookups -- Index on `definition_id` ensures fast lookups - -### Memory Management -- Request-level cache using Laravel Cache -- Limit batch size to 100 definitions per request -- Clear memory cache after request - -### UI Rendering -- Accordion lazy-loads groups (only render expanded) -- Pagination for policies with >50 groups -- Virtualized list for very large policies (future) - -### Caching TTL -- Database: 30 days (definitions change rarely) -- Memory: Request duration only -- Background refresh: Optional scheduled job - -## Security Considerations - -### Graph API Permissions -- Existing `DeviceManagementConfiguration.Read.All` sufficient -- No new permissions required - -### Data Sanitization -- Escape HTML in display names and descriptions -- Validate definition ID format before lookups -- Prevent XSS in value rendering - -### Audit Logging -- Log definition cache misses -- Log Graph API failures -- Track definition cache updates - -## Testing Strategy - -### Unit Tests -**File**: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` -- Test batch resolution -- Test caching behavior (memory + DB) -- Test fallback when definition not found -- Test Graph API error handling - -**File**: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` -- Test grouping logic -- Test value formatting (bool, int, choice, string) -- Test fallback labels -- Test nested group flattening - -### Feature Tests -**File**: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` -- Mock Graph API responses -- Assert tabs present for Settings Catalog policies -- Assert display names shown (not definition IDs) -- Assert values formatted correctly -- Assert search/filter works -- Assert JSON tab accessible -- Assert graceful degradation for missing definitions - -### Manual QA Checklist -1. Open Policy View for Settings Catalog policy -2. Verify tabs present: "Settings" and "JSON" -3. Verify Settings tab shows groups with accordion -4. Verify display names shown (not raw IDs) -5. Verify values formatted (True/False, numbers, etc.) -6. Test search: Type setting name, verify filtering -7. Switch to JSON tab, verify snapshot shown -8. Test copy button in JSON tab -9. Test dark mode toggle -10. Test with policy missing definitions (fallback labels) - -## Deployment Steps - -### 1. Database Migration -```bash -./vendor/bin/sail artisan migrate -``` - -### 2. Cache Warming (Optional) -```bash -./vendor/bin/sail artisan tinker ->>> $resolver = app(\App\Services\Intune\SettingsCatalogDefinitionResolver::class); ->>> $resolver->warmCache([...definitionIds...]); -``` - -### 3. Clear Caches -```bash -./vendor/bin/sail artisan optimize:clear -``` - -### 4. Verify -- Navigate to Policy View -- Check browser console for errors -- Check Laravel logs for Graph API errors - -## Rollback Plan - -### If Critical Issues Found -1. Revert database migration: - ```bash - ./vendor/bin/sail artisan migrate:rollback - ``` - -2. Revert code changes (Git): - ```bash - git revert - ``` - -3. Clear caches: - ```bash - ./vendor/bin/sail artisan optimize:clear - ``` - -### Partial Rollback -- Remove tabs, keep existing table view -- Disable definition resolver, show raw IDs -- Keep database table for future use - -## Dependencies on Feature 002 - -**Shared**: -- `pepperfm/filament-json` package (installed) -- JSON viewer CSS assets (published) -- Tab component pattern (Filament Schemas) - -**Independent**: -- Feature 185 can work without Feature 002 completed -- Feature 002 provided JSON tab foundation -- Feature 185 adds Settings tab with readable UI - -## Timeline Estimate - -- **Phase 1** (Foundation): 2-3 hours -- **Phase 2** (Snapshot): 1 hour -- **Phase 3** (Normalizer): 2-3 hours -- **Phase 4** (UI): 3-4 hours -- **Phase 5** (Testing): 2-3 hours -- **Total**: ~11-15 hours - -## Success Metrics - -1. **User Experience**: - - Admins can read policy settings without raw JSON - - Search finds settings in <200ms - - Accordion groups reduce scrolling - -2. **Performance**: - - Definition resolution: <500ms for 50 definitions - - UI render: <2s for 200 settings - - Search response: <200ms - -3. **Quality**: - - 100% test coverage for resolver - - Zero broken layouts for missing definitions - - Zero Graph API errors logged (with proper retry) - -4. **Adoption**: - - Settings tab used >80% of time vs JSON tab - - Zero support tickets about "unreadable settings" diff --git a/specs/185-settings-catalog-readable/spec.md b/specs/185-settings-catalog-readable/spec.md deleted file mode 100644 index 65fbba7..0000000 --- a/specs/185-settings-catalog-readable/spec.md +++ /dev/null @@ -1,240 +0,0 @@ -# Feature 185: Intune-like "Cleartext Settings" on Policy View - -## Overview -Display Settings Catalog policies in Policy View with human-readable setting names, descriptions, and formatted values—similar to Intune Portal experience—instead of raw JSON and definition IDs. - -## Problem Statement -Admins cannot effectively work with Settings Catalog policies when they only see: -- `settingDefinitionId` strings (e.g., `device_vendor_msft_passportforwork_biometrics_usebiometrics`) -- Raw JSON structures -- Choice values as GUIDs or internal strings - -This makes policy review, audit, and troubleshooting extremely difficult. - -## Goals -- **Primary**: Render Settings Catalog policies with display names, descriptions, grouped settings, and formatted values -- **Secondary**: Keep raw JSON available for audit/restore workflows -- **Tertiary**: Gracefully degrade when definition metadata is unavailable - -## User Stories - -### P1: US-UI-04 - Admin Views Readable Settings -**As an** Intune admin -**I want to** see policy settings with human-readable names and descriptions -**So that** I can understand what the policy configures without reading raw JSON - -**Acceptance Criteria:** -- Display name shown for each setting (not definition ID) -- Description/help text visible on hover or expand -- Values formatted appropriately (True/False, numbers, choice labels) -- Settings grouped by category/section - -### P2: US-UI-05 - Admin Searches/Filters Settings -**As an** Intune admin -**I want to** search and filter settings by name or value -**So that** I can quickly find specific configurations in large policies - -**Acceptance Criteria:** -- Search box filters settings list -- Search works on display name and value -- Results update instantly -- Clear search resets view - -### P3: US-UI-06 - Admin Accesses Raw JSON When Needed -**As an** Intune admin or auditor -**I want to** switch to raw JSON view -**So that** I can see the exact Graph API payload for audit/restore - -**Acceptance Criteria:** -- Tab navigation between "Settings" and "JSON" views -- JSON view shows complete policy snapshot -- JSON view includes copy-to-clipboard -- Settings view is default - -## Functional Requirements - -### FR-185.1: Setting Definition Resolver Service -- **Input**: Array of `settingDefinitionId` (including children from group settings) -- **Output**: Map of `{definitionId => {displayName, description, helpText, categoryId, uxBehavior, ...}}` -- **Strategy**: - - Fetch from Graph API settingDefinitions endpoints - - Cache in database (`settings_catalog_definitions` table) - - Memory cache for request-level performance - - Fallback to prettified ID if definition not found - -### FR-185.2: Database Schema for Definition Cache -**Table**: `settings_catalog_definitions` -- `id` (bigint, PK) -- `definition_id` (string, unique, indexed) -- `display_name` (string) -- `description` (text, nullable) -- `help_text` (text, nullable) -- `category_id` (string, nullable) -- `ux_behavior` (string, nullable) -- `raw` (jsonb) - full Graph response -- `timestamps` - -### FR-185.3: Snapshot Enrichment (Non-Blocking) -- After hydrating `/configurationPolicies/{id}/settings` -- Extract all `settingDefinitionId` + children -- Call resolver to warm cache -- Store render hints in snapshot metadata: `definitions_cached: true/false`, `definition_count: N` - -### FR-185.4: PolicyNormalizer Enhancement -- For `settingsCatalogPolicy` type: - - Output: `settings_groups[]` = `{title, description?, rows[]}` - - Each row: `{label, helpText?, value_display, value_raw, definition_id, instance_type}` - - Value formatting: - - `integer/bool`: show compact (True/False, numbers) - - `choice`: show friendly choice label (extract from `@odata.type` or value tail) - - `string`: truncate long values, add copy button - - Fallback: prettify `definitionId` if definition not found (e.g., `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name") - -### FR-185.5: Policy View UI Update -- **Layout**: 2-column - - Left: "Configuration Settings" (grouped, searchable) - - Right: "Policy Details" (existing metadata: name, type, platform, last synced) -- **Tabs**: - - "Settings" (default) - cleartext UI with accordion groups - - "JSON" - raw snapshot viewer (pepperfm/filament-json) -- **Search/Filter**: Live search on setting display name and value -- **Accordion**: Settings grouped by category, collapsible -- **Fallback**: Generic table for non-Settings Catalog policies (existing behavior) - -### FR-185.6: JSON Viewer Integration -- Use `pepperfm/filament-json` only on Policy View and Policy Version View -- Not rendered globally - -## Non-Functional Requirements - -### NFR-185.1: Performance -- Definition resolver: <500ms for batch of 50 definitions (cached) -- UI render: <2s for policy with 200 settings -- Search/filter: <200ms response time - -### NFR-185.2: Caching Strategy -- DB cache: 30 days TTL for definitions -- Memory cache: Request-level only -- Cache warming: Background job after policy sync (optional) - -### NFR-185.3: Graceful Degradation -- If definition not found: show prettified ID -- If Graph API fails: show cached data or fallback -- If no cache: show raw definition ID with info icon - -### NFR-185.4: Maintainability -- Resolver service isolated, testable -- Normalizer logic separated from UI -- UI components reusable for Version view - -## Technical Architecture - -### Services -1. **SettingsCatalogDefinitionResolver** (`app/Services/Intune/`) - - `resolve(array $definitionIds): array` - - `resolveOne(string $definitionId): ?array` - - `warmCache(array $definitionIds): void` - - Uses GraphClientInterface - - Database: `SettingsCatalogDefinition` model - -2. **PolicyNormalizer** (extend existing) - - `normalizeSettingsCatalog(array $snapshot, array $definitions): array` - - Returns structured groups + rows - -### Database -**Migration**: `create_settings_catalog_definitions_table` -**Model**: `SettingsCatalogDefinition` (Eloquent) - -### UI Components -**Resource**: `PolicyResource` (extend infolist) -- Tabs component -- Accordion for groups -- Search/filter component -- ViewEntry for settings table - -## Implementation Plan - -### Phase 1: Foundation (Resolver + DB) -1. Create migration `settings_catalog_definitions` -2. Create model `SettingsCatalogDefinition` -3. Create service `SettingsCatalogDefinitionResolver` -4. Add Graph client method for fetching definitions -5. Implement cache logic (DB + memory) - -### Phase 2: Snapshot Enrichment -1. Extend `PolicySnapshotService` to extract definition IDs -2. Call resolver after settings hydration -3. Store metadata in snapshot - -### Phase 3: Normalizer Enhancement -1. Extend `PolicyNormalizer` for Settings Catalog -2. Implement value formatting logic -3. Implement grouping logic -4. Add fallback for missing definitions - -### Phase 4: UI Implementation -1. Update `PolicyResource` infolist with tabs -2. Create accordion view for settings groups -3. Add search/filter functionality -4. Integrate JSON viewer (pepperfm) -5. Add fallback for non-Settings Catalog policies - -### Phase 5: Testing & Polish -1. Unit tests for resolver -2. Feature tests for UI -3. Manual QA on staging -4. Performance profiling - -## Testing Strategy - -### Unit Tests -- `SettingsCatalogDefinitionResolverTest` - - Test definition mapping - - Test caching behavior - - Test fallback logic - - Test batch resolution - -### Feature Tests -- `PolicyViewSettingsCatalogReadableTest` - - Mock Graph responses - - Assert UI shows display names - - Assert values formatted correctly - - Assert grouping works - - Assert search/filter works - - Assert JSON tab available - -## Success Criteria - -1. ✅ Admin sees human-readable setting names + descriptions -2. ✅ Values formatted appropriately (True/False, numbers, choice labels) -3. ✅ Settings grouped by category with accordion -4. ✅ Search/filter works on display name and value -5. ✅ Raw JSON available in separate tab -6. ✅ Unknown settings show prettified ID (no broken layout) -7. ✅ Performance: <2s render for 200 settings -8. ✅ Tests pass: Unit + Feature - -## Dependencies -- Existing: `PolicyNormalizer`, `PolicySnapshotService`, `GraphClientInterface` -- New: `pepperfm/filament-json` (already installed in Feature 002) -- Database: PostgreSQL with JSONB support - -## Risks & Mitigations -- **Risk**: Graph API rate limiting when fetching definitions - - **Mitigation**: Aggressive caching, batch requests, background warming -- **Risk**: Definition schema changes by Microsoft - - **Mitigation**: Raw JSONB storage allows flexible parsing, version metadata -- **Risk**: Large policies (1000+ settings) slow UI - - **Mitigation**: Pagination, lazy loading accordion groups, virtualized lists - -## Out of Scope -- Editing settings (read-only view only) -- Definition schema versioning -- Multi-language support for definitions -- Real-time definition updates (cache refresh manual/scheduled) - -## Future Enhancements -- Background job to pre-warm definition cache -- Definition schema versioning -- Comparison view between policy versions (diff) -- Export settings to CSV/Excel diff --git a/specs/185-settings-catalog-readable/tasks.md b/specs/185-settings-catalog-readable/tasks.md deleted file mode 100644 index 556b5c1..0000000 --- a/specs/185-settings-catalog-readable/tasks.md +++ /dev/null @@ -1,472 +0,0 @@ -# Feature 185: Settings Catalog Readable UI - Tasks - -## Summary -- **Total Tasks**: 42 -- **User Stories**: 3 (US-UI-04, US-UI-05, US-UI-06) -- **Estimated Time**: 11-15 hours -- **Phases**: 7 - -## FR→Task Traceability - -| FR | Description | Tasks | -|----|-------------|-------| -| FR-185.1 | Setting Definition Resolver Service | T003, T004, T005, T006, T007 | -| FR-185.2 | Database Schema | T001, T002 | -| FR-185.3 | Snapshot Enrichment | T008, T009, T010 | -| FR-185.4 | PolicyNormalizer Enhancement | T011, T012, T013, T014 | -| FR-185.5 | Policy View UI Update | T015-T024 | -| FR-185.6 | JSON Viewer Integration | T025 | - -## User Story→Task Mapping - -| User Story | Tasks | Success Criteria | -|------------|-------|------------------| -| US-UI-04 (Readable Settings) | T015-T020 | Display names shown, values formatted, grouped by category | -| US-UI-05 (Search/Filter) | T021, T022 | Search box works, filters settings, instant results | -| US-UI-06 (Raw JSON Access) | T023, T024, T025 | Tabs present, JSON view works, copy button functional | - -## Measurable Thresholds -- **Definition Resolution**: <500ms for batch of 50 definitions (cached) -- **UI Render**: <2s for policy with 200 settings -- **Search Response**: <200ms filter update -- **Database Cache TTL**: 30 days - ---- - -## Phase 1: Database Foundation (T001-T002) - -**Goal**: Create database schema for caching setting definitions - -- [X] **T001** Create migration for `settings_catalog_definitions` table - - Schema: id, definition_id (unique), display_name, description, help_text, category_id, ux_behavior, raw (jsonb), timestamps - - Indexes: definition_id (unique), category_id, raw (GIN) - - File: `database/migrations/2025_12_13_212126_create_settings_catalog_definitions_table.php` - - **Implementation Note**: Created migration with GIN index for JSONB, ran successfully - -- [X] **T002** Create `SettingsCatalogDefinition` Eloquent model - - Casts: raw → array - - Fillable: definition_id, display_name, description, help_text, category_id, ux_behavior, raw - - File: `app/Models/SettingsCatalogDefinition.php` - - **Implementation Note**: Added helper methods findByDefinitionId() and findByDefinitionIds() for efficient lookups - ---- - -## Phase 2: Definition Resolver Service (T003-T007) - -**Goal**: Implement service to fetch and cache setting definitions from Graph API - -**User Story**: US-UI-04 (foundation) - -- [X] **T003** [P] Create `SettingsCatalogDefinitionResolver` service skeleton - - Constructor: inject GraphClientInterface, SettingsCatalogDefinition model - - Methods: resolve(), resolveOne(), warmCache(), clearCache() - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Complete service with 3-tier caching (memory → DB → Graph API) - -- [X] **T004** [P] [US1] Implement `resolve(array $definitionIds): array` method - - Check memory cache (Laravel Cache) - - Check database cache - - Batch fetch missing from Graph API: `/deviceManagement/configurationSettings?$filter=id in (...)` - - Store in DB + memory cache - - Return map: `{definitionId => {displayName, description, ...}}` - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Implemented with batch request optimization and error handling - -- [X] **T005** [P] [US1] Implement `resolveOne(string $definitionId): ?array` method - - Single definition lookup - - Same caching strategy as resolve() - - Return null if not found - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Wraps resolve() for single ID lookup - -- [X] **T006** [US1] Implement fallback logic for missing definitions - - Prettify definition ID: `device_vendor_msft_policy_name` → "Device Vendor Msft Policy Name" - - Return fallback structure: `{displayName: prettified, description: null, ...}` - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: prettifyDefinitionId() method with Str::title() conversion, isFallback flag added - -- [X] **T007** [P] Implement `warmCache(array $definitionIds): void` method - - Pre-populate cache without returning data - - Non-blocking: catch and log Graph API errors - - File: `app/Services/Intune/SettingsCatalogDefinitionResolver.php` - - **Implementation Note**: Non-blocking implementation with try/catch, logs warnings on failure - ---- - -## Phase 3: Snapshot Enrichment (T008-T010) - -**Goal**: Extend PolicySnapshotService to warm definition cache after settings hydration - -**User Story**: US-UI-04 (foundation) - -- [X] **T008** [US1] Extend `PolicySnapshotService` to extract definition IDs - - After hydrating `/configurationPolicies/{id}/settings` - - Extract all `settingDefinitionId` from settings array - - Include children from `groupSettingCollectionInstance` - - File: `app/Services/Intune/PolicySnapshotService.php` - - **Implementation Note**: Added extractDefinitionIds() method with recursive extraction from nested children - -- [X] **T009** [US1] Call SettingsCatalogDefinitionResolver::warmCache() in snapshot flow - - Pass extracted definition IDs to resolver - - Non-blocking: use try/catch for Graph API calls - - File: `app/Services/Intune/PolicySnapshotService.php` - - **Implementation Note**: Integrated warmCache() call in hydrateSettingsCatalog() after settings extraction - -- [X] **T010** [US1] Add metadata to snapshot about definition cache status - - Add to snapshot: `definitions_cached: true/false`, `definition_count: N` - - Store with snapshot data - - File: `app/Services/Intune/PolicySnapshotService.php` - - **Implementation Note**: Added definitions_cached and definition_count to metadata - ---- - -## Phase 4: PolicyNormalizer Enhancement (T011-T014) - -**Goal**: Transform Settings Catalog snapshots into UI-ready grouped structure - -**User Story**: US-UI-04 - -- [X] **T011** [US1] Create `normalizeSettingsCatalogGrouped()` method in PolicyNormalizer - - Input: array $snapshot, array $definitions - - Output: array with groups[] structure - - Extract settings from snapshot - - Resolve definitions for all setting IDs - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Complete method with definition resolution integration - -- [X] **T012** [US1] Implement value formatting logic - - ChoiceSettingInstance: Extract choice label from @odata.type or value - - SimpleSetting (bool): "Enabled" / "Disabled" - - SimpleSetting (int): Number formatted with separators - - SimpleSetting (string): Truncate >100 chars, add "..." - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Added formatSettingsCatalogValue() method with all formatting rules - -- [X] **T013** [US1] Implement grouping logic by category - - Group settings by categoryId from definition metadata - - Fallback: Group by first segment of definition ID - - Sort groups alphabetically by title - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Added groupSettingsByCategory() with fallback extraction from definition IDs - -- [X] **T014** [US1] Implement nested group flattening for groupSettingCollectionInstance - - Recursively extract children from group settings - - Maintain hierarchy in output structure - - Include parent context in child labels - - File: `app/Services/Intune/PolicyNormalizer.php` - - **Implementation Note**: Recursive walk function handles nested children and group collections - ---- - -## Phase 5: UI Implementation - Settings Tab (T015-T022) - -**Goal**: Create readable Settings Catalog UI with accordion, search, and formatting - -**User Stories**: US-UI-04, US-UI-05 - -- [X] **T015** [US1] Add Tabs component to PolicyResource infolist for settingsCatalogPolicy - - Conditional rendering: only for settingsCatalogPolicy type - - Tab 1: "Settings" (default) - - Tab 2: "JSON" (existing from Feature 002) - - File: `app/Filament/Resources/PolicyResource.php` - - **Implementation Note**: Tabs already exist from Feature 002, extended Settings tab with grouped view - -- [X] **T016** [US1] Create Settings tab schema with search input - - TextInput for search/filter at top - - Placeholder: "Search settings..." - - Wire with Livewire for live filtering - - File: `app/Filament/Resources/PolicyResource.php` - - **Implementation Note**: Added search_info TextEntry (hidden for MVP), search implemented in Blade template - -- [X] **T017** [US1] Create Blade component for grouped settings accordion - - File: `resources/views/filament/infolists/entries/settings-catalog-grouped.blade.php` - - Props: groups (from normalizer), searchQuery - - Render accordion with Filament Section components - - **Implementation Note**: Complete Blade component with Filament Section integration - -- [X] **T018** [US1] Implement accordion group rendering - - Each group: Section with title + description - - Collapsible by default (first group expanded) - - Group header shows setting count - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Using x-filament::section with collapsible, first group expanded by default - -- [X] **T019** [US1] Implement setting row rendering within groups - - Layout: Label (bold) | Value (formatted) | Help icon - - Help icon: Tooltip with description + helpText - - Copy button for long values - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Flexbox layout with label, help text, value display, and Alpine.js copy button - -- [X] **T020** [US1] Add value formatting in Blade template - - Bool: Badge (Enabled/Disabled with colors) - - Int: Formatted number - - String: Truncate with "..." and expand button - - Choice: Show choice label - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Conditional rendering based on value type, badges for bool, monospace for int - -- [X] **T021** [US2] Implement search/filter logic in Livewire component - - Filter groups and settings by search query - - Search on display_name and value_display - - Update accordion to show only matching settings - - File: `app/Filament/Resources/PolicyResource.php` (or custom Livewire component) - - **Implementation Note**: Blade-level filtering using searchQuery prop, no Livewire component needed for MVP - -- [X] **T022** [US2] Add "No results" empty state for search - - Show message when search returns no matches - - Provide "Clear search" button - - File: `settings-catalog-grouped.blade.php` - - **Implementation Note**: Empty state with clear search button using wire:click - ---- - -## Phase 6: UI Implementation - Tabs & Fallback (T023-T025) - -**Goal**: Complete tab navigation and handle non-Settings Catalog policies - -**User Story**: US-UI-06 - -- [ ] **T023** [US3] Verify JSON tab still works (from Feature 002) - - Tab navigation switches correctly - - JSON viewer renders snapshot - - Copy button functional - - File: `app/Filament/Resources/PolicyResource.php` - -- [ ] **T024** [US3] Add fallback for policies without cached definitions - - Show info message in Settings tab: "Definitions not cached. Showing raw data." - - Display raw definition IDs with prettified labels - - Link to "View JSON" tab - - File: `settings-catalog-grouped.blade.php` - -- [ ] **T025** Ensure JSON viewer only renders on Policy View (not globally) - - Check existing implementation from Feature 002 - - Verify pepperfm/filament-json scoped correctly - - File: `app/Filament/Resources/PolicyResource.php` - ---- - -## Phase 7: Testing & Validation (T026-T042) - -**Goal**: Comprehensive testing for resolver, normalizer, and UI - -### Unit Tests (T026-T031) - -- [ ] **T026** [P] Create `SettingsCatalogDefinitionResolverTest` test file - - File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - - Setup: Mock GraphClientInterface, in-memory database - -- [ ] **T027** [P] Test `resolve()` method with batch of definition IDs - - Assert: Returns map with display names - - Assert: Caches in database - - Assert: Uses cached data on second call - - File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - -- [ ] **T028** [P] Test fallback logic for missing definitions - - Mock: Graph API returns 404 - - Assert: Returns prettified definition ID - - Assert: No exception thrown - - File: `tests/Unit/SettingsCatalogDefinitionResolverTest.php` - -- [ ] **T029** [P] Create `PolicyNormalizerSettingsCatalogTest` test file - - File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - - Setup: Mock definition data, sample snapshot - -- [ ] **T030** [P] Test grouping logic in normalizer - - Input: Snapshot with settings from different categories - - Assert: Groups created correctly - - Assert: Groups sorted alphabetically - - File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - -- [ ] **T031** [P] Test value formatting in normalizer - - Test bool → "Enabled"/"Disabled" - - Test int → formatted number - - Test string → truncation - - Test choice → label extraction - - File: `tests/Unit/PolicyNormalizerSettingsCatalogTest.php` - -### Feature Tests (T032-T037) - -- [ ] **T032** [P] Create `PolicyViewSettingsCatalogReadableTest` test file - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - - Setup: Mock GraphClient, create test policy with Settings Catalog type - -- [ ] **T033** Test Settings Catalog policy view shows tabs - - Navigate to Policy View - - Assert: Tabs component present - - Assert: "Settings" and "JSON" tabs visible - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T034** Test Settings tab shows display names (not definition IDs) - - Mock: Definitions cached - - Assert: Display names shown in UI - - Assert: Definition IDs NOT visible - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T035** Test values formatted correctly - - Mock: Settings with bool, int, string, choice values - - Assert: Bool shows "Enabled"/"Disabled" - - Assert: Int shows formatted number - - Assert: String shows truncated value - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T036** [US2] Test search/filter functionality - - Input: Type search query - - Assert: Settings list filtered - - Assert: Only matching settings shown - - Assert: Clear search resets view - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -- [ ] **T037** Test graceful degradation for missing definitions - - Mock: Definitions not cached - - Assert: Fallback labels shown (prettified IDs) - - Assert: No broken layout - - Assert: Info message visible - - File: `tests/Feature/Filament/PolicyViewSettingsCatalogReadableTest.php` - -### Validation & Polish (T038-T042) - -- [ ] **T038** Run Pest test suite for Feature 185 - - Command: `./vendor/bin/sail artisan test --filter=SettingsCatalog` - - Assert: All tests pass - - Fix any failures - -- [ ] **T039** Run Laravel Pint on modified files - - Command: `./vendor/bin/sail pint --dirty` - - Assert: No style issues - - Commit fixes - -- [ ] **T040** Review git changes for Feature 185 - - Check: No changes to forbidden areas (see constitution) - - Verify: Only expected files modified - - Document: List of changed files in research.md - -- [ ] **T041** Run database migration on local environment - - Command: `./vendor/bin/sail artisan migrate` - - Verify: `settings_catalog_definitions` table created - - Check: Indexes applied correctly - -- [ ] **T042** Manual QA: Policy View with Settings Catalog policy - - Navigate to Policy View for Settings Catalog policy - - Verify: Tabs present ("Settings" and "JSON") - - Verify: Settings tab shows accordion with groups - - Verify: Display names shown (not raw IDs) - - Verify: Values formatted correctly - - Test: Search filters settings - - Test: JSON tab works - - Test: Copy buttons functional - - Test: Dark mode toggle - ---- - -## Dependencies & Execution Order - -### Sequential Dependencies -- **Phase 1** → **Phase 2**: Database must exist before resolver can cache -- **Phase 2** → **Phase 3**: Resolver must exist before snapshot enrichment -- **Phase 2** → **Phase 4**: Definitions needed for normalizer -- **Phase 4** → **Phase 5**: Normalized data structure needed for UI -- **Phase 5** → **Phase 7**: UI must exist before feature tests - -### Parallel Opportunities -- **Phase 2** (T003-T007): Resolver methods can be implemented in parallel -- **Phase 4** (T011-T014): Normalizer sub-methods can be implemented in parallel -- **Phase 5** (T015-T022): UI components can be developed in parallel after T015 -- **Phase 7** (T026-T031): Unit tests can be written in parallel -- **Phase 7** (T032-T037): Feature tests can be written in parallel - -### Example Parallel Execution -**Phase 2**: -- Developer A: T003, T004 (resolve methods) -- Developer B: T005, T006 (resolveOne + fallback) -- Both converge for T007 (warmCache) - -**Phase 5**: -- Developer A: T015-T017 (tabs + accordion setup) -- Developer B: T018-T020 (rendering logic) -- Both converge for T021-T022 (search functionality) - ---- - -## Task Complexity Estimates - -| Phase | Task Count | Estimated Time | Dependencies | -|-------|------------|----------------|--------------| -| Phase 1: Database | 2 | ~30 min | None | -| Phase 2: Resolver | 5 | ~2-3 hours | Phase 1 | -| Phase 3: Snapshot | 3 | ~1 hour | Phase 2 | -| Phase 4: Normalizer | 4 | ~2-3 hours | Phase 2 | -| Phase 5: UI Settings | 8 | ~3-4 hours | Phase 4 | -| Phase 6: UI Tabs | 3 | ~1 hour | Phase 5 | -| Phase 7: Testing | 17 | ~3-4 hours | Phase 2-6 | -| **Total** | **42** | **11-15 hours** | | - ---- - -## Success Criteria Checklist - -- [X] **SC-001**: Admin sees human-readable setting names (not definition IDs) on Policy View (Implementation complete - requires manual verification) -- [X] **SC-002**: Setting values formatted appropriately (True/False, numbers, choice labels) (Implementation complete - requires manual verification) -- [X] **SC-003**: Settings grouped by category with accordion (collapsible sections) (Implementation complete - requires manual verification) -- [X] **SC-004**: Search/filter works on display name and value (<200ms response) (Blade-level implementation complete - requires manual verification) -- [X] **SC-005**: Raw JSON available in separate "JSON" tab (Feature 002 integration preserved) -- [X] **SC-006**: Unknown settings show prettified ID fallback (no broken layout) (Implementation complete - requires manual verification) -- [ ] **SC-007**: Performance: <2s render for policy with 200 settings (Requires load testing) -- [ ] **SC-008**: Tests pass: Unit tests for resolver + normalizer (Tests not written yet) -- [ ] **SC-009**: Tests pass: Feature tests for UI rendering (Tests not written yet) -- [ ] **SC-010**: Definition resolution: <500ms for batch of 50 (cached) (Requires benchmark testing) - ---- - -## Constitution Compliance Evidence - -| Principle | Evidence | Tasks | -|-----------|----------|-------| -| Safety-First | Read-only UI, no edit capabilities | All UI tasks | -| Immutable Versioning | Snapshot enrichment non-blocking, metadata only | T008-T010 | -| Defensive Restore | Not applicable (read-only feature) | N/A | -| Auditability | Raw JSON still accessible via tab | T023-T025 | -| Tenant-Aware | Resolver respects tenant scoping (via GraphClient) | T003-T007 | -| Graph Abstraction | Uses existing GraphClientInterface | T003-T007 | -| Spec-Driven | Full spec + plan + tasks before implementation | This document | - ---- - -## Risk Mitigation Tasks - -- **Risk**: Graph API rate limiting - - **Mitigation**: T007 (warmCache is non-blocking), aggressive DB caching -- **Risk**: Definition schema changes by Microsoft - - **Mitigation**: T001 (raw JSONB storage), T006 (fallback logic) -- **Risk**: Large policies slow UI - - **Mitigation**: T017-T018 (accordion lazy-loading), performance tests in T042 - ---- - -## Notes for Implementation - -1. **Feature 002 Dependency**: Feature 185 uses tabs from Feature 002 JSON viewer implementation. Ensure Feature 002 code is stable before starting Phase 5. - -2. **Database Migration**: Run migration early (T001) to avoid blocking later phases. - -3. **Graph API Endpoints**: Verify access to `/deviceManagement/configurationSettings` endpoint in test environment before implementing T004. - -4. **Testing Strategy**: Write unit tests (Phase 7, T026-T031) in parallel with implementation to enable TDD workflow. - -5. **UI Polish**: Leave time for manual QA (T042) to catch UX issues not covered by automated tests. - -6. **Performance Profiling**: Use Laravel Telescope or Debugbar during T042 to measure actual performance vs NFR targets. - ---- - -## Implementation Readiness - -**Prerequisites**: -- ✅ Feature 002 JSON viewer implemented (tabs pattern established) -- ✅ pepperfm/filament-json installed -- ✅ GraphClientInterface available -- ✅ PolicyNormalizer exists -- ✅ PolicySnapshotService exists -- ✅ PostgreSQL with JSONB support - -**Ready to Start**: Phase 1 (Database Foundation) diff --git a/tests/Feature/BackupItemReaddTest.php b/tests/Feature/BackupItemReaddTest.php new file mode 100644 index 0000000..b077fd5 --- /dev/null +++ b/tests/Feature/BackupItemReaddTest.php @@ -0,0 +1,135 @@ +tenant = Tenant::create([ + 'tenant_id' => 'tenant-123', + 'name' => 'Test Tenant', + ]); + $this->tenant->makeCurrent(); + + $this->user = User::factory()->create(); + $this->actingAs($this->user); + + $this->policy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'policy-456', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Test Policy', + 'platform' => 'windows', + ]); + + $this->backupSet = BackupSet::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Test Backup Set', + 'status' => 'completed', + 'created_by' => $this->user->email, + ]); +}); + +it('excludes soft-deleted items when listing available policies to add', function () { + // Create a backup item + $backupItem = BackupItem::create([ + 'tenant_id' => $this->tenant->id, + 'backup_set_id' => $this->backupSet->id, + 'policy_id' => $this->policy->id, + 'policy_identifier' => $this->policy->external_id, + 'policy_type' => $this->policy->policy_type, + 'platform' => $this->policy->platform, + 'payload' => ['test' => 'data'], + 'captured_at' => now(), + ]); + + // Get available policies (should be empty since policy is already in backup) + $existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all(); + + expect($existingPolicyIds)->toContain($this->policy->id); + + // Soft-delete the backup item + $backupItem->delete(); + + // Verify it's soft-deleted + expect($this->backupSet->items()->count())->toBe(0); + expect($this->backupSet->items()->withTrashed()->count())->toBe(1); + + // Get available policies again - soft-deleted items should NOT be in the list (UI can re-add them) + $existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all(); + + expect($existingPolicyIds)->not->toContain($this->policy->id) + ->and($existingPolicyIds)->toHaveCount(0); +}); + +it('prevents re-adding soft-deleted policies via BackupService', function () { + // Create initial backup item + $backupItem = BackupItem::create([ + 'tenant_id' => $this->tenant->id, + 'backup_set_id' => $this->backupSet->id, + 'policy_id' => $this->policy->id, + 'policy_identifier' => $this->policy->external_id, + 'policy_type' => $this->policy->policy_type, + 'platform' => $this->policy->platform, + 'payload' => ['test' => 'data'], + 'captured_at' => now(), + ]); + + // Soft-delete it + $backupItem->delete(); + + // Try to add the same policy again via BackupService + $service = app(BackupService::class); + + $result = $service->addPoliciesToSet( + tenant: $this->tenant, + backupSet: $this->backupSet->refresh(), + policyIds: [$this->policy->id], + actorEmail: $this->user->email, + actorName: $this->user->name, + ); + + // Should restore the soft-deleted item, not create a new one + expect($this->backupSet->items()->count())->toBe(1) + ->and($this->backupSet->items()->withTrashed()->count())->toBe(1) + ->and($result->item_count)->toBe(1) + ->and($backupItem->fresh()->deleted_at)->toBeNull(); // Item should be restored +}); + +it('allows adding different policy after one was soft-deleted', function () { + // Create initial backup item + $backupItem = BackupItem::create([ + 'tenant_id' => $this->tenant->id, + 'backup_set_id' => $this->backupSet->id, + 'policy_id' => $this->policy->id, + 'policy_identifier' => $this->policy->external_id, + 'policy_type' => $this->policy->policy_type, + 'platform' => $this->policy->platform, + 'payload' => ['test' => 'data'], + 'captured_at' => now(), + ]); + + // Soft-delete it + $backupItem->delete(); + + // Create a different policy + $otherPolicy = Policy::create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'policy-789', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Other Policy', + 'platform' => 'windows', + ]); + + // Check available policies - should include the new one but not the deleted one + $existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); + + expect($existingPolicyIds)->toContain($this->policy->id) + ->and($existingPolicyIds)->not->toContain($otherPolicy->id); +}); diff --git a/tests/Feature/BackupWithAssignmentsConsistencyTest.php b/tests/Feature/BackupWithAssignmentsConsistencyTest.php new file mode 100644 index 0000000..2a43ee6 --- /dev/null +++ b/tests/Feature/BackupWithAssignmentsConsistencyTest.php @@ -0,0 +1,264 @@ +tenant = Tenant::factory()->create(['status' => 'active']); + + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'test-policy-123', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows10', + 'display_name' => 'Test Policy', + ]); + + $this->snapshotPayload = [ + '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy', + 'id' => 'test-policy-123', + 'name' => 'Test Policy', + 'description' => 'Test Description', + 'platforms' => 'windows10', + 'technologies' => 'mdm', + 'settings' => [], + ]; + + $this->assignmentsPayload = [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + ], + ], + [ + 'id' => 'assignment-2', + 'target' => [ + '@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget', + ], + ], + ]; + + $this->resolvedAssignments = [ + [ + 'id' => 'assignment-1', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + 'group_name' => 'Test Group', + ], + ], + [ + 'id' => 'assignment-2', + 'target' => [ + '@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget', + ], + ], + ]; + + // Mock PolicySnapshotService + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->andReturn([ + 'payload' => $this->snapshotPayload, + 'metadata' => ['fetched_at' => now()->toISOString()], + 'warnings' => [], + ]); + }); + + // Mock AssignmentFetcher + $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->andReturn($this->assignmentsPayload); + }); + + // Mock GroupResolver + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->andReturn([ + 'group-123' => [ + 'id' => 'group-123', + 'displayName' => 'Test Group', + 'orphaned' => false, + ], + ]); + }); +}); + +it('creates backup with includeAssignments=true and both BackupItem and PolicyVersion have assignments', function () { + $backupService = app(BackupService::class); + + $backupSet = $backupService->createBackupSet( + tenant: $this->tenant, + policyIds: [$this->policy->id], + actorEmail: 'test@example.com', + actorName: 'Test User', + name: 'Test Backup With Assignments', + includeAssignments: true, + ); + + expect($backupSet)->not->toBeNull(); + expect($backupSet->items)->toHaveCount(1); + + $backupItem = $backupSet->items->first(); + expect($backupItem->assignments)->not->toBeNull(); + expect($backupItem->assignments)->toBeArray(); + expect($backupItem->assignments)->toHaveCount(2); + expect($backupItem->assignments[0]['target']['groupId'])->toBe('group-123'); + + // CRITICAL: PolicyVersion must also have assignments (domain consistency) + expect($backupItem->policy_version_id)->not->toBeNull(); + $version = PolicyVersion::find($backupItem->policy_version_id); + expect($version)->not->toBeNull(); + expect($version->assignments)->not->toBeNull(); + expect($version->assignments)->toBeArray(); + expect($version->assignments)->toHaveCount(2); + expect($version->assignments[0]['target']['groupId'])->toBe('group-123'); + + // Verify assignments match between BackupItem and PolicyVersion + expect($backupItem->assignments)->toEqual($version->assignments); +}); + +it('creates backup with includeAssignments=false and both BackupItem and PolicyVersion have no assignments', function () { + $backupService = app(BackupService::class); + + $backupSet = $backupService->createBackupSet( + tenant: $this->tenant, + policyIds: [$this->policy->id], + actorEmail: 'test@example.com', + actorName: 'Test User', + name: 'Test Backup Without Assignments', + includeAssignments: false, + ); + + expect($backupSet)->not->toBeNull(); + expect($backupSet->items)->toHaveCount(1); + + $backupItem = $backupSet->items->first(); + expect($backupItem->assignments)->toBeNull(); + + // CRITICAL: PolicyVersion must also have no assignments (domain consistency) + expect($backupItem->policy_version_id)->not->toBeNull(); + $version = PolicyVersion::find($backupItem->policy_version_id); + expect($version)->not->toBeNull(); + expect($version->assignments)->toBeNull(); +}); + +it('backfills existing PolicyVersion without assignments when creating backup with includeAssignments=true', function () { + // Create an existing PolicyVersion without assignments (simulate old backup) + $existingVersion = PolicyVersion::create([ + 'policy_id' => $this->policy->id, + 'tenant_id' => $this->tenant->id, + 'version_number' => 1, + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows10', + 'snapshot' => $this->snapshotPayload, + 'assignments' => null, // NO ASSIGNMENTS + 'scope_tags' => null, + 'assignments_hash' => null, + 'scope_tags_hash' => null, + 'created_by' => 'legacy-system@example.com', + ]); + + expect($existingVersion->assignments)->toBeNull(); + expect($existingVersion->assignments_hash)->toBeNull(); + + $backupService = app(BackupService::class); + + // Create new backup with includeAssignments=true + // Orchestrator should detect existing version and backfill it + $backupSet = $backupService->createBackupSet( + tenant: $this->tenant, + policyIds: [$this->policy->id], + actorEmail: 'test@example.com', + actorName: 'Test User', + name: 'Test Backup Backfills Version', + includeAssignments: true, + ); + + expect($backupSet)->not->toBeNull(); + expect($backupSet->items)->toHaveCount(1); + + $backupItem = $backupSet->items->first(); + + // BackupItem should have assignments + expect($backupItem->assignments)->not->toBeNull(); + expect($backupItem->assignments)->toHaveCount(2); + + // CRITICAL: Existing PolicyVersion should now be backfilled (idempotent) + // The orchestrator should have detected same payload_hash and enriched it + $existingVersion->refresh(); + expect($existingVersion->assignments)->not->toBeNull(); + expect($existingVersion->assignments)->toHaveCount(2); + expect($existingVersion->assignments_hash)->not->toBeNull(); + expect($existingVersion->assignments[0]['target']['groupId'])->toBe('group-123'); + + // BackupItem should reference the backfilled version + expect($backupItem->policy_version_id)->toBe($existingVersion->id); +}); + +it('does not overwrite existing PolicyVersion assignments when they already exist (idempotent)', function () { + // Create an existing PolicyVersion WITH assignments + $existingAssignments = [ + [ + 'id' => 'old-assignment', + 'target' => ['@odata.type' => '#microsoft.graph.allLicensedUsersAssignmentTarget'], + ], + ]; + + $existingVersion = PolicyVersion::create([ + 'policy_id' => $this->policy->id, + 'tenant_id' => $this->tenant->id, + 'version_number' => 1, + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => 'windows10', + 'snapshot' => $this->snapshotPayload, + 'assignments' => $existingAssignments, + 'scope_tags' => null, + 'assignments_hash' => hash('sha256', json_encode($existingAssignments)), + 'scope_tags_hash' => null, + 'created_by' => 'previous-backup@example.com', + ]); + + $backupService = app(BackupService::class); + + // Create new backup - orchestrator should NOT overwrite existing assignments + $backupSet = $backupService->createBackupSet( + tenant: $this->tenant, + policyIds: [$this->policy->id], + actorEmail: 'test@example.com', + actorName: 'Test User', + name: 'Test Backup Preserves Existing', + includeAssignments: true, + ); + + expect($backupSet)->not->toBeNull(); + expect($backupSet->items)->toHaveCount(1); + + $backupItem = $backupSet->items->first(); + + // BackupItem should have NEW assignments (from current fetch) + expect($backupItem->assignments)->not->toBeNull(); + expect($backupItem->assignments)->toHaveCount(2); + expect($backupItem->assignments[0]['target']['groupId'])->toBe('group-123'); + + // CRITICAL: Existing PolicyVersion should NOT be modified (idempotent) + $existingVersion->refresh(); + expect($existingVersion->assignments)->toEqual($existingAssignments); + expect($existingVersion->assignments)->toHaveCount(1); + expect($existingVersion->assignments[0]['id'])->toBe('old-assignment'); + + // BackupItem should reference the existing version (reused) + expect($backupItem->policy_version_id)->toBe($existingVersion->id); +}); diff --git a/tests/Feature/Filament/BackupCreationTest.php b/tests/Feature/Filament/BackupCreationTest.php index 46cfc76..896b6f3 100644 --- a/tests/Feature/Filament/BackupCreationTest.php +++ b/tests/Feature/Filament/BackupCreationTest.php @@ -2,16 +2,43 @@ use App\Filament\Resources\BackupSetResource; use App\Models\Policy; +use App\Models\PolicyVersion; use App\Models\Tenant; use App\Models\User; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphResponse; +use App\Services\Graph\ScopeTagResolver; +use App\Services\Intune\PolicySnapshotService; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; +use Mockery\MockInterface; uses(RefreshDatabase::class); test('backup creation captures snapshots and audit log', function () { + // Mock PolicySnapshotService + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch') + ->twice() // Called once for each policy + ->andReturnUsing(function ($tenant, $policy) { + return [ + 'payload' => [ + 'id' => $policy->external_id, + 'name' => $policy->display_name, + 'roleScopeTagIds' => ['0'], + ], + 'metadata' => [], + 'warnings' => [], + ]; + }); + }); + + // Mock ScopeTagResolver + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolve') + ->andReturn([['id' => '0', 'displayName' => 'Default']]); + }); + app()->bind(GraphClientInterface::class, fn () => new class implements GraphClientInterface { public function listPolicies(string $policyType, array $options = []): GraphResponse @@ -59,6 +86,7 @@ public function request(string $method, string $path, array $options = []): Grap 'policy_type' => 'deviceConfiguration', 'display_name' => 'Policy A', 'platform' => 'windows', + 'last_synced_at' => now(), ]); $policyB = Policy::create([ @@ -67,6 +95,7 @@ public function request(string $method, string $path, array $options = []): Grap 'policy_type' => 'deviceCompliancePolicy', 'display_name' => 'Policy B', 'platform' => 'windows', + 'last_synced_at' => now(), ]); $user = User::factory()->create(); @@ -81,13 +110,23 @@ public function request(string $method, string $path, array $options = []): Grap 'pageClass' => \App\Filament\Resources\BackupSetResource\Pages\ViewBackupSet::class, ])->callTableAction('addPolicies', data: [ 'policy_ids' => [$policyA->id, $policyB->id], + 'include_assignments' => false, + 'include_scope_tags' => true, ]); $backupSet->refresh(); expect($backupSet->item_count)->toBe(2); expect($backupSet->items)->toHaveCount(2); - expect($backupSet->items->first()->payload['policyId'])->toBe('policy-1'); + expect($backupSet->items->first()->payload['id'])->toBe('policy-1'); + + $firstVersion = PolicyVersion::find($backupSet->items->first()->policy_version_id); + expect($firstVersion)->not->toBeNull(); + expect($firstVersion->scope_tags)->toBe([ + 'ids' => ['0'], + 'names' => ['Default'], + ]); + expect($firstVersion->assignments)->toBeNull(); $this->assertDatabaseHas('audit_logs', [ 'action' => 'backup.created', diff --git a/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php new file mode 100644 index 0000000..f547b1b --- /dev/null +++ b/tests/Feature/Filament/PolicyCaptureSnapshotOptionsTest.php @@ -0,0 +1,64 @@ +create(); + $tenant->makeCurrent(); + $policy = Policy::factory()->for($tenant)->create([ + 'external_id' => 'policy-123', + ]); + + $user = User::factory()->create(); + $this->actingAs($user); + + $this->mock(PolicySnapshotService::class, function (MockInterface $mock) use ($policy) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => $policy->external_id, + 'name' => $policy->display_name, + 'roleScopeTagIds' => ['0'], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('fetch')->never(); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + Livewire::test(ViewPolicy::class, ['record' => $policy->getRouteKey()]) + ->callAction('capture_snapshot', data: [ + 'include_assignments' => false, + 'include_scope_tags' => true, + ]); + + $version = $policy->versions()->first(); + + expect($version)->not->toBeNull(); + expect($version->assignments)->toBeNull(); + expect($version->scope_tags)->toBe([ + 'ids' => ['0'], + 'names' => ['Default'], + ]); +}); diff --git a/tests/Feature/Filament/PolicyListingTest.php b/tests/Feature/Filament/PolicyListingTest.php index f9c8be1..6099fcb 100644 --- a/tests/Feature/Filament/PolicyListingTest.php +++ b/tests/Feature/Filament/PolicyListingTest.php @@ -21,6 +21,7 @@ 'policy_type' => 'deviceConfiguration', 'display_name' => 'Policy A', 'platform' => 'windows', + 'last_synced_at' => now(), ]); $otherTenant = Tenant::create([ @@ -35,6 +36,7 @@ 'policy_type' => 'deviceConfiguration', 'display_name' => 'Policy B', 'platform' => 'windows', + 'last_synced_at' => now(), ]); $user = User::factory()->create(); diff --git a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php index a183d93..ec69858 100644 --- a/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php +++ b/tests/Feature/Filament/SettingsCatalogRestoreApplySettingsPatchTest.php @@ -169,9 +169,28 @@ public function request(string $method, string $path, array $options = []): Grap expect($client->requestCalls[0]['method'])->toBe('POST'); expect($client->requestCalls[0]['path'])->toBe('deviceManagement/configurationPolicies/scp-3/settings'); + $results = $run->results; + $results[0]['assignment_summary'] = [ + 'success' => 0, + 'failed' => 1, + 'skipped' => 0, + ]; + $results[0]['assignment_outcomes'] = [[ + 'status' => 'failed', + 'group_id' => 'group-1', + 'mapped_group_id' => 'group-2', + 'reason' => 'Graph create failed', + 'graph_error_message' => 'Bad request', + ]]; + + $run->update(['results' => $results]); + $response = $this->get(route('filament.admin.resources.restore-runs.view', ['record' => $run])); $response->assertOk(); $response->assertSee('Graph bulk apply failed'); $response->assertSee('Setting missing'); $response->assertSee('req-setting-404'); + $response->assertSee('Assignments: 0 success'); + $response->assertSee('Assignment details'); + $response->assertSee('Graph create failed'); }); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 5661fa2..2d0d171 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -83,7 +83,7 @@ public function request(string $method, string $path, array $options = []): Grap $this->assertDatabaseHas('tenant_permissions', [ 'tenant_id' => $tenant->id, - 'status' => 'ok', + 'status' => 'granted', ]); }); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php new file mode 100644 index 0000000..eb977a6 --- /dev/null +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -0,0 +1,90 @@ +tenant = Tenant::factory()->create(); + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + $this->user = User::factory()->create(); +}); + +it('displays policy version page', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); +}); + +it('displays assignments widget when version has assignments', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + 'assignment_filter_name' => 'Targeted Devices', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + ], + ], + [ + 'id' => 'assignment-2', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget', + 'groupId' => 'group-456', + 'deviceAndAppManagementAssignmentFilterId' => null, + 'deviceAndAppManagementAssignmentFilterType' => 'none', + ], + ], + ], + 'scope_tags' => [ + 'ids' => ['0'], + 'names' => ['Default'], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSeeLivewire('policy-version-assignments-widget'); + $response->assertSee('2 assignment(s)'); + $response->assertSee('Include group'); + $response->assertSee('Exclude group'); + $response->assertSee('Filter (include): Targeted Devices'); +}); + +it('displays empty state when version has no assignments', function () { + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'assignments' => null, + ]); + + $this->actingAs($this->user); + + $response = $this->get("/admin/policy-versions/{$version->id}"); + + $response->assertOk(); + $response->assertSee('Assignments were not captured for this version'); +}); diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php new file mode 100644 index 0000000..f2a2b4b --- /dev/null +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -0,0 +1,249 @@ + + */ + 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, []); + } +} + +test('restore applies assignments with mapped groups', function () { + $applyResponse = new GraphResponse(true, []); + $requestResponses = [ + new GraphResponse(true, []), // assign action + ]; + + $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); + 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' => 'scp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog Alpha', + '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, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + 'group_display_name' => 'Source One', + ], + ], + [ + 'id' => 'assignment-2', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-2', + ], + ], + ], + ]); + + $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, + groupMapping: [ + 'source-group-1' => 'target-group-1', + 'source-group-2' => 'target-group-2', + ], + ); + + $summary = $run->results[0]['assignment_summary'] ?? null; + + expect($summary)->not->toBeNull(); + expect($summary['success'])->toBe(2); + expect($summary['failed'])->toBe(0); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(1); + expect($postCalls[0]['path'])->toBe('/deviceManagement/configurationPolicies/scp-1/assign'); + + $payloadAssignments = $postCalls[0]['payload']['assignments'] ?? []; + $groupIds = collect($payloadAssignments)->pluck('target.groupId')->all(); + + expect($groupIds)->toBe(['target-group-1', 'target-group-2']); + expect($payloadAssignments[0])->not->toHaveKey('id'); +}); + +test('restore handles assignment failures gracefully', function () { + $applyResponse = new GraphResponse(true, []); + $requestResponses = [ + new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [ + ['code' => 'BadRequest', 'message' => 'Bad request'], + ], [], [ + 'error_code' => 'BadRequest', + 'error_message' => 'Bad request', + ]), // assign action fails + ]; + + $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); + 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' => 'scp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog Alpha', + '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, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + [ + 'id' => 'assignment-2', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-2', + ], + ], + ], + ]); + + $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, + groupMapping: [ + 'source-group-1' => 'target-group-1', + 'source-group-2' => 'target-group-2', + ], + ); + + $summary = $run->results[0]['assignment_summary'] ?? null; + + expect($summary)->not->toBeNull(); + expect($summary['success'])->toBe(0); + expect($summary['failed'])->toBe(2); + expect($run->results[0]['status'])->toBe('partial'); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php new file mode 100644 index 0000000..2397796 --- /dev/null +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -0,0 +1,173 @@ + '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, + '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); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + 'backup_item_ids' => [$backupItem->id], + ]) + ->assertFormFieldVisible('group_mapping.source-group-1'); +}); + +test('restore wizard persists group mapping selections', 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, + '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(function (string $id) { + $resolved = $id === 'target-group-1'; + + return [$id => [ + 'id' => $id, + 'displayName' => $resolved ? 'Target Group' : null, + 'orphaned' => ! $resolved, + ]]; + }) + ->all(); + }); + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + 'backup_item_ids' => [$backupItem->id], + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + 'is_dry_run' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::first(); + + expect($run)->not->toBeNull(); + expect($run->group_mapping)->toBe([ + 'source-group-1' => 'target-group-1', + ]); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'restore.group_mapping.applied', + ]); +}); diff --git a/tests/Feature/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php new file mode 100644 index 0000000..459ef23 --- /dev/null +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -0,0 +1,200 @@ +tenant = Tenant::factory()->create(); + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'test-policy-id', + ]); + + $this->mock(ScopeTagResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); +}); + +it('captures policy version with assignments from graph', function () { + // Mock dependencies + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'test-policy-id', + 'name' => 'Test Policy', + 'settings' => [], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-123', + 'deviceAndAppManagementAssignmentFilterId' => 'filter-123', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + ], + ], + ]); + }); + + $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([ + ['id' => 'filter-123', 'displayName' => 'Targeted Devices'], + ]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version)->not->toBeNull() + ->and($version->assignments)->not->toBeNull() + ->and($version->assignments)->toHaveCount(1) + ->and($version->assignments[0]['target']['groupId'])->toBe('group-123') + ->and($version->assignments_hash)->not->toBeNull() + ->and($version->metadata['assignments_count'])->toBe(1) + ->and($version->metadata['has_orphaned_assignments'])->toBeFalse() + ->and($version->scope_tags)->toBe([ + 'ids' => ['0'], + 'names' => ['Default'], + ]); + + expect($version->assignments[0]['target']['group_display_name'])->toBe('Test Group'); + expect($version->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); +}); + +it('captures policy version without assignments when none exist', function () { + // Mock dependencies + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'test-policy-id', + 'name' => 'Test Policy', + 'settings' => [], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([]); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version)->not->toBeNull() + ->and($version->assignments)->toBeNull() + ->and($version->assignments_hash)->toBeNull(); +}); + +it('handles assignment fetch failure gracefully', function () { + // Mock dependencies + $this->mock(PolicySnapshotService::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andReturn([ + 'payload' => [ + 'id' => 'test-policy-id', + 'name' => 'Test Policy', + 'settings' => [], + ], + ]); + }); + + $this->mock(AssignmentFetcher::class, function ($mock) { + $mock->shouldReceive('fetch') + ->once() + ->andThrow(new \Exception('Graph API error')); + }); + + $versionService = app(VersionService::class); + $version = $versionService->captureFromGraph( + $this->tenant, + $this->policy, + 'test@example.com' + ); + + expect($version)->not->toBeNull() + ->and($version->assignments)->toBeNull() + ->and($version->metadata['assignments_fetch_failed'])->toBeTrue() + ->and($version->metadata['assignments_fetch_error'])->toBe('Graph API error'); +}); + +it('calculates correct hash for assignments', function () { + $assignments = [ + ['id' => '1', 'target' => ['groupId' => 'group-1']], + ['id' => '2', 'target' => ['groupId' => 'group-2']], + ]; + + $version = $this->policy->versions()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 1, + 'policy_type' => 'deviceManagementConfigurationPolicy', + 'snapshot' => ['test' => 'data'], + 'assignments' => $assignments, + 'assignments_hash' => hash('sha256', json_encode($assignments)), + 'captured_at' => now(), + ]); + + $expectedHash = hash('sha256', json_encode($assignments)); + + expect($version->assignments_hash)->toBe($expectedHash); + + // Verify same assignments produce same hash + $version2 = $this->policy->versions()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $this->policy->id, + 'version_number' => 2, + 'policy_type' => 'deviceManagementConfigurationPolicy', + 'snapshot' => ['test' => 'data'], + 'assignments' => $assignments, + 'assignments_hash' => hash('sha256', json_encode($assignments)), + 'captured_at' => now(), + ]); + + expect($version2->assignments_hash)->toBe($version->assignments_hash); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php new file mode 100644 index 0000000..f685067 --- /dev/null +++ b/tests/Unit/AssignmentFetcherTest.php @@ -0,0 +1,163 @@ +graphClient = Mockery::mock(MicrosoftGraphClient::class); + $this->fetcher = new AssignmentFetcher($this->graphClient); +}); + +test('primary endpoint success', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $assignments = [ + ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], + ['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']], + ]; + + $response = new GraphResponse( + success: true, + data: ['value' => $assignments] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($response); + + $result = $this->fetcher->fetch($tenantId, $policyId); + + expect($result)->toBe($assignments); +}); + +test('fallback on empty response', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + $assignments = [ + ['id' => 'assign-1', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-1']], + ]; + + // Primary returns empty + $primaryResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($primaryResponse); + + // Fallback returns assignments + $fallbackResponse = new GraphResponse( + success: true, + data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', '/deviceManagement/configurationPolicies', [ + 'tenant' => $tenantId, + 'query' => [ + '$expand' => 'assignments', + '$filter' => "id eq '{$policyId}'", + ], + ]) + ->andReturn($fallbackResponse); + + $result = $this->fetcher->fetch($tenantId, $policyId); + + expect($result)->toBe($assignments); +}); + +test('fail soft on error', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); + + $result = $this->fetcher->fetch($tenantId, $policyId); + + expect($result)->toBe([]); +}); + +test('returns empty array when both endpoints return empty', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + + // Primary returns empty + $primaryResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->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($tenantId, $policyId); + + expect($result)->toBe([]); +}); + +test('fallback handles missing assignments key', function () { + $tenantId = 'tenant-123'; + $policyId = 'policy-456'; + + // Primary returns empty + $primaryResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($primaryResponse); + + // Fallback returns policy without assignments key + $fallbackResponse = new GraphResponse( + success: true, + data: ['value' => [['id' => $policyId]]] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($fallbackResponse); + + $result = $this->fetcher->fetch($tenantId, $policyId); + + expect($result)->toBe([]); +}); diff --git a/tests/Unit/AssignmentFilterResolverTest.php b/tests/Unit/AssignmentFilterResolverTest.php new file mode 100644 index 0000000..dc6888e --- /dev/null +++ b/tests/Unit/AssignmentFilterResolverTest.php @@ -0,0 +1,65 @@ +graphClient = Mockery::mock(MicrosoftGraphClient::class); + $this->resolver = new AssignmentFilterResolver($this->graphClient); +}); + +test('resolves assignment filters by id', function () { + $filters = [ + ['id' => 'filter-1', 'displayName' => 'Targeted Devices'], + ['id' => 'filter-2', 'displayName' => 'Excluded Devices'], + ]; + + $response = new GraphResponse( + success: true, + data: ['value' => $filters] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', '/deviceManagement/assignmentFilters', [ + 'query' => [ + '$select' => 'id,displayName', + ], + ]) + ->andReturn($response); + + $result = $this->resolver->resolve(['filter-1']); + + expect($result)->toHaveCount(1) + ->and($result[0]['id'])->toBe('filter-1') + ->and($result[0]['displayName'])->toBe('Targeted Devices'); +}); + +test('uses cache for repeated lookups', function () { + $filters = [ + ['id' => 'filter-1', 'displayName' => 'Targeted Devices'], + ]; + + $response = new GraphResponse( + success: true, + data: ['value' => $filters] + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($response); + + $result1 = $this->resolver->resolve(['filter-1']); + $result2 = $this->resolver->resolve(['filter-1']); + + expect($result1)->toBe($result2); +}); diff --git a/tests/Unit/BackupItemTest.php b/tests/Unit/BackupItemTest.php new file mode 100644 index 0000000..e87ffcd --- /dev/null +++ b/tests/Unit/BackupItemTest.php @@ -0,0 +1,157 @@ +create([ + 'assignments' => [ + ['id' => 'abc-123', 'target' => ['groupId' => 'group-1']], + ], + ]); + + expect($backupItem->assignments)->toBeArray() + ->and($backupItem->assignments)->toHaveCount(1); +}); + +test('getAssignmentCountAttribute returns correct count', function () { + $backupItem = BackupItem::factory()->create([ + 'assignments' => [ + ['id' => 'abc-123', 'target' => ['groupId' => 'group-1']], + ['id' => 'def-456', 'target' => ['groupId' => 'group-2']], + ], + ]); + + expect($backupItem->assignment_count)->toBe(2); +}); + +test('getAssignmentCountAttribute returns zero for null assignments', function () { + $backupItem = BackupItem::factory()->create([ + 'assignments' => null, + ]); + + expect($backupItem->assignment_count)->toBe(0); +}); + +test('hasAssignments returns true when assignments exist', function () { + $backupItem = BackupItem::factory()->create([ + 'assignments' => [ + ['id' => 'abc-123', 'target' => ['groupId' => 'group-1']], + ], + ]); + + expect($backupItem->hasAssignments())->toBeTrue(); +}); + +test('hasAssignments returns false when assignments are null', function () { + $backupItem = BackupItem::factory()->create([ + 'assignments' => null, + ]); + + expect($backupItem->hasAssignments())->toBeFalse(); +}); + +test('getGroupIdsAttribute extracts unique group IDs', function () { + $backupItem = BackupItem::factory()->create([ + 'assignments' => [ + ['id' => 'abc-123', 'target' => ['groupId' => 'group-1']], + ['id' => 'def-456', 'target' => ['groupId' => 'group-2']], + ['id' => 'ghi-789', 'target' => ['groupId' => 'group-1']], // duplicate + ], + ]); + + expect($backupItem->group_ids)->toHaveCount(2) + ->and($backupItem->group_ids)->toContain('group-1', 'group-2'); +}); + +test('getScopeTagIdsAttribute returns scope tag IDs from metadata', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [ + 'scope_tag_ids' => ['0', 'abc-123', 'def-456'], + ], + ]); + + expect($backupItem->scope_tag_ids)->toHaveCount(3) + ->and($backupItem->scope_tag_ids)->toContain('0', 'abc-123', 'def-456'); +}); + +test('getScopeTagIdsAttribute returns default when not in metadata', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [], + ]); + + expect($backupItem->scope_tag_ids)->toBe(['0']); +}); + +test('getScopeTagNamesAttribute returns scope tag names from metadata', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [ + 'scope_tag_names' => ['Default', 'HR-Admins', 'Finance'], + ], + ]); + + expect($backupItem->scope_tag_names)->toHaveCount(3) + ->and($backupItem->scope_tag_names)->toContain('Default', 'HR-Admins', 'Finance'); +}); + +test('getScopeTagNamesAttribute returns default when not in metadata', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [], + ]); + + expect($backupItem->scope_tag_names)->toBe(['Default']); +}); + +test('hasOrphanedAssignments returns true when flag is set', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [ + 'has_orphaned_assignments' => true, + ], + ]); + + expect($backupItem->hasOrphanedAssignments())->toBeTrue(); +}); + +test('hasOrphanedAssignments returns false when flag is not set', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [], + ]); + + expect($backupItem->hasOrphanedAssignments())->toBeFalse(); +}); + +test('assignmentsFetchFailed returns true when flag is set', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [ + 'assignments_fetch_failed' => true, + ], + ]); + + expect($backupItem->assignmentsFetchFailed())->toBeTrue(); +}); + +test('assignmentsFetchFailed returns false when flag is not set', function () { + $backupItem = BackupItem::factory()->create([ + 'metadata' => [], + ]); + + expect($backupItem->assignmentsFetchFailed())->toBeFalse(); +}); + +test('scopeWithAssignments filters items with assignments', function () { + BackupItem::factory()->create(['assignments' => null]); + BackupItem::factory()->create(['assignments' => []]); + $withAssignments = BackupItem::factory()->create([ + 'assignments' => [ + ['id' => 'abc-123', 'target' => ['groupId' => 'group-1']], + ], + ]); + + $result = BackupItem::withAssignments()->get(); + + expect($result)->toHaveCount(1) + ->and($result->first()->id)->toBe($withAssignments->id); +}); diff --git a/tests/Unit/GroupResolverTest.php b/tests/Unit/GroupResolverTest.php new file mode 100644 index 0000000..2611fc9 --- /dev/null +++ b/tests/Unit/GroupResolverTest.php @@ -0,0 +1,179 @@ +graphClient = Mockery::mock(MicrosoftGraphClient::class); + $this->resolver = new GroupResolver($this->graphClient); +}); + +test('resolves all groups', function () { + $tenantId = 'tenant-123'; + $groupIds = ['group-1', 'group-2', 'group-3']; + $graphData = [ + 'value' => [ + ['id' => 'group-1', 'displayName' => 'All Users'], + ['id' => 'group-2', 'displayName' => 'HR Team'], + ['id' => 'group-3', 'displayName' => 'Contractors'], + ], + ]; + + $response = new GraphResponse( + success: true, + data: $graphData + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', '/directoryObjects/getByIds', [ + 'tenant' => $tenantId, + 'json' => [ + 'ids' => $groupIds, + 'types' => ['group'], + ], + ]) + ->andReturn($response); + + $result = $this->resolver->resolveGroupIds($groupIds, $tenantId); + + expect($result)->toHaveKey('group-1') + ->and($result['group-1'])->toBe([ + 'id' => 'group-1', + 'displayName' => 'All Users', + 'orphaned' => false, + ]) + ->and($result)->toHaveKey('group-2') + ->and($result['group-2']['orphaned'])->toBeFalse() + ->and($result)->toHaveKey('group-3') + ->and($result['group-3']['orphaned'])->toBeFalse(); +}); + +test('handles orphaned ids', function () { + $tenantId = 'tenant-123'; + $groupIds = ['group-1', 'group-2', 'group-3']; + $graphData = [ + 'value' => [ + ['id' => 'group-1', 'displayName' => 'All Users'], + // group-2 and group-3 are missing (deleted) + ], + ]; + + $response = new GraphResponse( + success: true, + data: $graphData + ); + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($response); + + $result = $this->resolver->resolveGroupIds($groupIds, $tenantId); + + expect($result)->toHaveKey('group-1') + ->and($result['group-1']['orphaned'])->toBeFalse() + ->and($result)->toHaveKey('group-2') + ->and($result['group-2'])->toBe([ + 'id' => 'group-2', + 'displayName' => null, + 'orphaned' => true, + ]) + ->and($result)->toHaveKey('group-3') + ->and($result['group-3']['orphaned'])->toBeTrue(); +}); + +test('caches results', function () { + $tenantId = 'tenant-123'; + $groupIds = ['group-1', 'group-2']; + $graphData = [ + 'value' => [ + ['id' => 'group-1', 'displayName' => 'All Users'], + ['id' => 'group-2', 'displayName' => 'HR Team'], + ], + ]; + + $response = new GraphResponse( + success: true, + data: $graphData + ); + + // First call - should hit Graph API + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($response); + + $result1 = $this->resolver->resolveGroupIds($groupIds, $tenantId); + + // Second call - should use cache (no Graph API call) + $result2 = $this->resolver->resolveGroupIds($groupIds, $tenantId); + + expect($result1)->toBe($result2) + ->and($result1)->toHaveCount(2); +}); + +test('returns empty array for empty input', function () { + $result = $this->resolver->resolveGroupIds([], 'tenant-123'); + + expect($result)->toBe([]); +}); + +test('handles graph exception gracefully', function () { + $tenantId = 'tenant-123'; + $groupIds = ['group-1', 'group-2']; + + $this->graphClient + ->shouldReceive('request') + ->once() + ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); + + $result = $this->resolver->resolveGroupIds($groupIds, $tenantId); + + // All groups should be marked as orphaned on failure + expect($result)->toHaveKey('group-1') + ->and($result['group-1']['orphaned'])->toBeTrue() + ->and($result['group-1']['displayName'])->toBeNull() + ->and($result)->toHaveKey('group-2') + ->and($result['group-2']['orphaned'])->toBeTrue(); +}); + +test('cache key is consistent regardless of array order', function () { + $tenantId = 'tenant-123'; + $groupIds1 = ['group-1', 'group-2', 'group-3']; + $groupIds2 = ['group-3', 'group-1', 'group-2']; // Different order + $graphData = [ + 'value' => [ + ['id' => 'group-1', 'displayName' => 'All Users'], + ['id' => 'group-2', 'displayName' => 'HR Team'], + ['id' => 'group-3', 'displayName' => 'Contractors'], + ], + ]; + + $response = new GraphResponse( + success: true, + data: $graphData + ); + + // First call with groupIds1 + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($response); + + $result1 = $this->resolver->resolveGroupIds($groupIds1, $tenantId); + + // Second call with groupIds2 (different order) - should use cache + $result2 = $this->resolver->resolveGroupIds($groupIds2, $tenantId); + + expect($result1)->toBe($result2); +}); diff --git a/tests/Unit/RestoreRunTest.php b/tests/Unit/RestoreRunTest.php new file mode 100644 index 0000000..65352a5 --- /dev/null +++ b/tests/Unit/RestoreRunTest.php @@ -0,0 +1,181 @@ +create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + 'source-group-2' => 'target-group-2', + ], + ]); + + expect($restoreRun->group_mapping)->toBeArray() + ->and($restoreRun->group_mapping)->toHaveCount(2); +}); + +test('hasGroupMapping returns true when mapping exists', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + expect($restoreRun->hasGroupMapping())->toBeTrue(); +}); + +test('hasGroupMapping returns false when mapping is null', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => null, + ]); + + expect($restoreRun->hasGroupMapping())->toBeFalse(); +}); + +test('getMappedGroupId returns mapped group ID', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + expect($restoreRun->getMappedGroupId('source-group-1'))->toBe('target-group-1'); +}); + +test('getMappedGroupId returns null for unmapped group', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + expect($restoreRun->getMappedGroupId('source-group-2'))->toBeNull(); +}); + +test('isGroupSkipped returns true for skipped group', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'SKIP', + ], + ]); + + expect($restoreRun->isGroupSkipped('source-group-1'))->toBeTrue(); +}); + +test('isGroupSkipped returns false for mapped group', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + expect($restoreRun->isGroupSkipped('source-group-1'))->toBeFalse(); +}); + +test('getUnmappedGroupIds returns groups without mapping', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + $unmapped = $restoreRun->getUnmappedGroupIds(['source-group-1', 'source-group-2', 'source-group-3']); + + expect($unmapped)->toHaveCount(2) + ->and($unmapped)->toContain('source-group-2', 'source-group-3'); +}); + +test('addGroupMapping adds new mapping', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + $restoreRun->addGroupMapping('source-group-2', 'target-group-2'); + + expect($restoreRun->group_mapping)->toHaveCount(2) + ->and($restoreRun->group_mapping['source-group-2'])->toBe('target-group-2'); +}); + +test('addGroupMapping overwrites existing mapping', function () { + $restoreRun = RestoreRun::factory()->create([ + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + ]); + + $restoreRun->addGroupMapping('source-group-1', 'target-group-new'); + + expect($restoreRun->group_mapping)->toHaveCount(1) + ->and($restoreRun->group_mapping['source-group-1'])->toBe('target-group-new'); +}); + +test('getAssignmentRestoreOutcomes returns outcomes from results', function () { + $restoreRun = RestoreRun::factory()->create([ + 'results' => [ + 'assignment_outcomes' => [ + ['status' => 'success', 'assignment' => []], + ['status' => 'failed', 'assignment' => []], + ], + ], + ]); + + expect($restoreRun->getAssignmentRestoreOutcomes())->toHaveCount(2); +}); + +test('getAssignmentRestoreOutcomes returns empty array when not set', function () { + $restoreRun = RestoreRun::factory()->create([ + 'results' => [], + ]); + + expect($restoreRun->getAssignmentRestoreOutcomes())->toBeEmpty(); +}); + +test('getSuccessfulAssignmentsCount returns correct count', function () { + $restoreRun = RestoreRun::factory()->create([ + 'results' => [ + 'assignment_outcomes' => [ + ['status' => 'success', 'assignment' => []], + ['status' => 'success', 'assignment' => []], + ['status' => 'failed', 'assignment' => []], + ['status' => 'skipped', 'assignment' => []], + ], + ], + ]); + + expect($restoreRun->getSuccessfulAssignmentsCount())->toBe(2); +}); + +test('getFailedAssignmentsCount returns correct count', function () { + $restoreRun = RestoreRun::factory()->create([ + 'results' => [ + 'assignment_outcomes' => [ + ['status' => 'success', 'assignment' => []], + ['status' => 'failed', 'assignment' => []], + ['status' => 'failed', 'assignment' => []], + ], + ], + ]); + + expect($restoreRun->getFailedAssignmentsCount())->toBe(2); +}); + +test('getSkippedAssignmentsCount returns correct count', function () { + $restoreRun = RestoreRun::factory()->create([ + 'results' => [ + 'assignment_outcomes' => [ + ['status' => 'success', 'assignment' => []], + ['status' => 'skipped', 'assignment' => []], + ['status' => 'skipped', 'assignment' => []], + ['status' => 'skipped', 'assignment' => []], + ], + ], + ]); + + expect($restoreRun->getSkippedAssignmentsCount())->toBe(3); +}); diff --git a/tests/Unit/ScopeTagResolverTest.php b/tests/Unit/ScopeTagResolverTest.php new file mode 100644 index 0000000..b83ad10 --- /dev/null +++ b/tests/Unit/ScopeTagResolverTest.php @@ -0,0 +1,142 @@ +create(); + + $mockGraphClient = Mockery::mock(MicrosoftGraphClient::class); + $mockGraphClient->shouldReceive('request') + ->with('GET', '/deviceManagement/roleScopeTags', Mockery::on(function ($options) use ($tenant) { + return $options['query']['$select'] === 'id,displayName' + && $options['tenant'] === $tenant->external_id + && $options['client_id'] === $tenant->app_client_id + && $options['client_secret'] === $tenant->app_client_secret; + })) + ->once() + ->andReturn(new GraphResponse( + success: true, + data: [ + 'value' => [ + ['id' => '0', 'displayName' => 'Default'], + ['id' => '1', 'displayName' => 'Verbund-1'], + ['id' => '2', 'displayName' => 'Verbund-2'], + ], + ] + )); + + $mockLogger = Mockery::mock(GraphLogger::class); + + $resolver = new ScopeTagResolver($mockGraphClient, $mockLogger); + $result = $resolver->resolve(['0', '1', '2'], $tenant); + + expect($result)->toBe([ + ['id' => '0', 'displayName' => 'Default'], + ['id' => '1', 'displayName' => 'Verbund-1'], + ['id' => '2', 'displayName' => 'Verbund-2'], + ]); +}); + +test('caches scope tag objects for 1 hour', function () { + $tenant = Tenant::factory()->create(); + + $mockGraphClient = Mockery::mock(MicrosoftGraphClient::class); + $mockGraphClient->shouldReceive('request') + ->once() // Only called once due to caching + ->andReturn(new GraphResponse( + success: true, + data: [ + 'value' => [ + ['id' => '0', 'displayName' => 'Default'], + ], + ] + )); + + $mockLogger = Mockery::mock(GraphLogger::class); + + $resolver = new ScopeTagResolver($mockGraphClient, $mockLogger); + + // First call - fetches from API + $result1 = $resolver->resolve(['0'], $tenant); + + // Second call - should use cache + $result2 = $resolver->resolve(['0'], $tenant); + + expect($result1)->toBe([['id' => '0', 'displayName' => 'Default']]); + expect($result2)->toBe([['id' => '0', 'displayName' => 'Default']]); +}); + +test('returns empty array for empty input', function () { + $tenant = Tenant::factory()->create(); + $mockGraphClient = Mockery::mock(MicrosoftGraphClient::class); + $mockLogger = Mockery::mock(GraphLogger::class); + + $resolver = new ScopeTagResolver($mockGraphClient, $mockLogger); + $result = $resolver->resolve([], $tenant); + + expect($result)->toBe([]); +}); + +test('handles 403 forbidden gracefully', function () { + $tenant = Tenant::factory()->create(); + + $mockGraphClient = Mockery::mock(MicrosoftGraphClient::class); + $mockGraphClient->shouldReceive('request') + ->once() + ->andReturn(new GraphResponse( + success: false, + status: 403, + data: [] + )); + + $mockLogger = Mockery::mock(GraphLogger::class); + + $resolver = new ScopeTagResolver($mockGraphClient, $mockLogger); + $result = $resolver->resolve(['0', '1'], $tenant); + + // Should return empty array when 403 + expect($result)->toBe([]); +}); + +test('filters returned scope tags to requested IDs', function () { + $tenant = Tenant::factory()->create(); + + $mockGraphClient = Mockery::mock(MicrosoftGraphClient::class); + $mockGraphClient->shouldReceive('request') + ->once() + ->andReturn(new GraphResponse( + success: true, + data: [ + 'value' => [ + ['id' => '0', 'displayName' => 'Default'], + ['id' => '1', 'displayName' => 'Verbund-1'], + ['id' => '2', 'displayName' => 'Verbund-2'], + ], + ] + )); + + $mockLogger = Mockery::mock(GraphLogger::class); + + $resolver = new ScopeTagResolver($mockGraphClient, $mockLogger); + // Request only IDs 0 and 2 + $result = $resolver->resolve(['0', '2'], $tenant); + + expect($result)->toHaveCount(2); + // Note: array_filter preserves keys, so result will be [0 => ..., 2 => ...] + expect($result[0])->toBe(['id' => '0', 'displayName' => 'Default']); + expect($result[2])->toBe(['id' => '2', 'displayName' => 'Verbund-2']); +}); + diff --git a/tests/Unit/TenantPermissionServiceTest.php b/tests/Unit/TenantPermissionServiceTest.php index 5a36be1..74c2c51 100644 --- a/tests/Unit/TenantPermissionServiceTest.php +++ b/tests/Unit/TenantPermissionServiceTest.php @@ -42,14 +42,14 @@ function requiredPermissions(): array TenantPermission::create([ 'tenant_id' => $tenant->id, 'permission_key' => $permission['key'], - 'status' => 'ok', + 'status' => 'granted', ]); } $result = app(TenantPermissionService::class)->compare($tenant); - expect($result['overall_status'])->toBe('ok'); - expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'ok')->count()) + expect($result['overall_status'])->toBe('granted'); + expect(TenantPermission::where('tenant_id', $tenant->id)->where('status', 'granted')->count()) ->toBe(count(requiredPermissions())); });