diff --git a/README.md b/README.md index 77c54c98..ddc34ef8 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 d2bdbdd1..c2dee2af 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -367,6 +367,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 ac662271..fde907c8 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 2ef039ef..acfe7f1a 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -214,6 +214,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 ad2188ff..17c7b1b8 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 83ac3d11..291192c0 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 41747100..44820682 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -11,6 +11,8 @@ use App\Models\RestoreRun; use App\Models\Tenant; use App\Services\BulkOperationService; +use App\Services\Graph\GraphClientInterface; +use App\Services\Graph\GroupResolver; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; @@ -21,13 +23,16 @@ 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\Contracts\HasTable; use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Str; use UnitEnum; class RestoreRunResource extends Resource @@ -63,6 +68,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)') @@ -95,7 +104,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), @@ -407,6 +466,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 cc845b02..7f7baf37 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 00000000..7bb8d895 --- /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 00000000..5bdec759 --- /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 00000000..6211e6cd --- /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 dde4183e..9f9ad2c9 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 4b97a9b1..0994797f 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 2e93372b..fe6bdcf1 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', ]; @@ -46,4 +47,81 @@ public function isDeletable(): bool return in_array($status, ['completed', 'failed', 'aborted', 'completed_with_errors', 'partial', 'previewed'], true); } + + // 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 00000000..f94c697c --- /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 00000000..1505be7f --- /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 00000000..53d3ec6c --- /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 00000000..ab7b0eb8 --- /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 00000000..afac6a45 --- /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 00000000..0460eeb2 --- /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 a3109b22..dd8aee49 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 00000000..0ac4573d --- /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 726f7ce4..71a82105 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 32f3a7f3..65eb18ba 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 83ab3fae..3d4f4da0 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 50beb46e..e77eff50 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 56ba8e16..935c3682 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 00000000..5dbae1b6 --- /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 00000000..ed09eb9a --- /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 index 2480d83e..7ec96b2d 100644 --- a/database/factories/PolicyFactory.php +++ b/database/factories/PolicyFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Models\Tenant; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -17,12 +18,13 @@ class PolicyFactory extends Factory public function definition(): array { return [ - 'tenant_id' => \App\Models\Tenant::factory(), + 'tenant_id' => Tenant::factory(), 'external_id' => fake()->uuid(), 'display_name' => fake()->words(3, true), - 'policy_type' => 'deviceConfiguration', - 'platform' => 'windows10AndLater', - 'metadata' => ['key' => 'value'], + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']), + 'last_synced_at' => now(), + 'metadata' => [], ]; } } diff --git a/database/factories/PolicyVersionFactory.php b/database/factories/PolicyVersionFactory.php index 0e1dde36..04c2c1db 100644 --- a/database/factories/PolicyVersionFactory.php +++ b/database/factories/PolicyVersionFactory.php @@ -22,8 +22,8 @@ public function definition(): array 'tenant_id' => Tenant::factory(), 'policy_id' => Policy::factory(), 'version_number' => 1, - 'policy_type' => 'deviceConfiguration', - 'platform' => 'windows10AndLater', + 'policy_type' => 'settingsCatalogPolicy', + 'platform' => fake()->randomElement(['android', 'iOS', 'macOS', 'windows10']), 'created_by' => fake()->safeEmail(), 'captured_at' => now(), 'snapshot' => ['example' => true], diff --git a/database/factories/RestoreRunFactory.php b/database/factories/RestoreRunFactory.php new file mode 100644 index 00000000..e3b2afd5 --- /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 index a1770acc..0938ebe8 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -18,10 +18,16 @@ public function definition(): array { return [ 'name' => fake()->company(), - 'tenant_id' => fake()->uuid(), 'external_id' => fake()->uuid(), + 'tenant_id' => fake()->uuid(), + 'app_client_id' => fake()->uuid(), + 'app_client_secret' => null, // Skip encryption in tests + 'app_certificate_thumbprint' => null, + 'app_status' => 'ok', + 'app_notes' => null, 'status' => 'active', - 'is_current' => true, + 'is_current' => false, + '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 00000000..77a3066e --- /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 00000000..6f3d5e53 --- /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 00000000..efc7a049 --- /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 00000000..c92f20ec --- /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 00000000..6eabcb8b --- /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 03a16e01..0c4cf379 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 2f15b6fc..8c429e33 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 00000000..8205dfd9 --- /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 00000000..fb6238b4 --- /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/004-assignments-scope-tags/data-model.md b/specs/004-assignments-scope-tags/data-model.md new file mode 100644 index 00000000..036e4977 --- /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 00000000..70416cd1 --- /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 00000000..71fedaa4 --- /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 00000000..c5810cd3 --- /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 index cca658ba..2011a3ce 100644 --- a/specs/004-assignments-scope-tags/spec.md +++ b/specs/004-assignments-scope-tags/spec.md @@ -28,59 +28,58 @@ ## Scope - **Policy Types**: `settingsCatalogPolicy` only (initially) - **Graph Endpoints**: - GET `/deviceManagement/configurationPolicies/{id}/assignments` - - POST/PATCH `/deviceManagement/configurationPolicies/{id}/assign` + - POST `/deviceManagement/configurationPolicies/{id}/assign` (assign action, replaces assignments) + - DELETE `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` (fallback) - GET `/deviceManagement/roleScopeTags` (for reference data) -- **Backup Behavior**: Optional (checkbox "Include Assignments & Scope Tags") + - 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 - Backup with Assignments & Scope Tags (Priority: P1) +### User Story 1 - Capture Assignments & Scope Tags (Priority: P1) -**As an admin**, I want to optionally include assignments and scope tags when backing up Settings Catalog policies, so that I have complete policy state for migration or disaster recovery. +**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 create a new Backup Set for Settings Catalog policies, - **When** I enable the checkbox "Include Assignments & Scope Tags", - **Then** the backup captures: - - Assignment list (groups, users, devices with include/exclude mode) - - Scope Tag IDs referenced by the policy - - Metadata about assignment count and scope tag names +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 view a Backup Set with assignments included, - **When** I expand a Backup Item detail, - **Then** I see: - - "Assignments: 3 groups, 2 users" summary - - "Scope Tags: Default, HR-Admins" list - - JSON tab with full assignment payload +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 create a Backup Set without enabling the checkbox, - **When** the backup completes, - **Then** assignments and scope tags are NOT captured (payload-only backup) +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 View with Assignments Tab (Priority: P1) +### User Story 2 - Policy Version View with Assignments (Priority: P1) -**As an admin**, I want to see a policy's current assignments and scope tags in the Policy View, so I understand its targeting and visibility. +**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, - **When** I navigate to the "Assignments" tab, +1. **Given** I view a Settings Catalog Policy Version, + **When** assignments were captured, **Then** I see: - - Table with columns: Type (Group/User/Device), Name, Mode (Include/Exclude), ID - - "Scope Tags" section showing: Default, HR-Admins (editable IDs) - - "Not assigned" message if no assignments exist + - 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** a policy has 10 assignments, - **When** I filter by "Include only" or "Exclude only", - **Then** the table filters accordingly +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** assignments include deleted groups (orphaned IDs), - **When** I view the assignments tab, - **Then** orphaned entries show as "Unknown Group (ID: abc-123)" with warning badge +3. **Given** scope tags were not captured, + **When** I view the version, + **Then** I see a "Scope tags not captured" empty state. --- @@ -135,45 +134,51 @@ ## Functional Requirements ### Backup & Storage -**FR-004.1**: System MUST provide a checkbox "Include Assignments & Scope Tags" on the Backup Set creation form (default: unchecked). +**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. Store: - - Assignment array (each with: `target` object, `id`, `intent`, filters) - - Extracted metadata: group names (resolved via `/directoryObjects/getByIds`), user UPNs, device IDs - - Warning flags for orphaned IDs - - Fallback flag: `assignments_fetch_method` (direct | expand | failed) +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 store Scope Tag IDs in backup metadata (from policy payload `roleScopeTagIds` field). +**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**: Backup Item `metadata` JSONB field MUST include: -```json -{ - "assignment_count": 5, - "scope_tag_ids": ["0", "abc-123"], - "scope_tag_names": ["Default", "HR-Admins"], - "has_orphaned_assignments": false -} -``` +**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**: System MUST gracefully handle Graph API failures when fetching assignments (log warning, continue backup with flag `assignments_fetch_failed: true`). +**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 View MUST show an "Assignments" tab for Settings Catalog policies displaying: -- Assignments table (type, name, mode, ID) +**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 no assignments +- Empty state if assignments or scope tags were not captured -**FR-004.7**: Backup Item detail view MUST show assignment count and scope tag names in metadata summary. +**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 group IDs by calling POST `/directoryObjects/getByIds` with batch of group IDs extracted from source assignments (types: ["group"]). Missing IDs = unresolved. +**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) @@ -186,15 +191,18 @@ ### Restore with Group Mapping 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 DELETE-then-CREATE pattern: - - Step 1: GET existing assignments from target policy - - Step 2: DELETE each existing assignment (via DELETE `/assignments/{id}`) - - Step 3: POST each new/mapped assignment (via POST `/assignments`) +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) @@ -213,9 +221,9 @@ ### Scope Tags **FR-004.16**: System MUST resolve Scope Tag names via `/deviceManagement/roleScopeTags` (with caching, TTL 1 hour). -**FR-004.17**: During restore, system SHOULD preserve Scope Tag IDs if they exist in target tenant, or: -- Log warning if Scope Tag ID doesn't exist in target -- Allow policy creation to proceed (Graph API default behavior) +**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". @@ -223,7 +231,7 @@ ### Scope Tags ## Non-Functional Requirements -**NFR-004.1**: Assignment fetching MUST NOT block backup creation (async or fail-soft). +**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. @@ -235,12 +243,33 @@ ## Non-Functional Requirements ## Data Model Changes -### Migration: `backup_items` table extension +### 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'); - // stores: [{target:{...}, id, intent, filters}, ...] + // copy of PolicyVersion assignments for restore safety }); ``` @@ -253,18 +282,28 @@ ### Migration: `restore_runs` table extension }); ``` -### `backup_items.metadata` JSONB schema +### `policy_versions.scope_tags` JSONB schema ```json { - "assignment_count": 5, - "scope_tag_ids": ["0", "123"], - "scope_tag_names": ["Default", "HR"], + "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 @@ -280,30 +319,32 @@ ### Endpoints to Add (Production-Tested Strategies) - Client-side filter to extract assignments - **Reason**: Known Graph API quirks with assignment expansion on certain template families -2. **Assignment CRUD Operations** (Standard Graph Pattern) +2. **Assignment Apply** (Assign action + fallback) - - **POST** `/deviceManagement/configurationPolicies/{id}/assignments` - - Body: Single assignment object - - Returns: 201 Created with assignment object + - **POST** `/deviceManagement/configurationPolicies/{id}/assign` + - Body: `{ "assignments": [ ... ] }` + - Returns: 200/204 on success (no per-assignment IDs) - Example: ```json { - "target": { - "@odata.type": "#microsoft.graph.groupAssignmentTarget", - "groupId": "abc-123-def" - }, - "intent": "apply" + "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) - - **PATCH** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - - Body: Assignment object (partial update) - - Returns: 200 OK with updated assignment - - - **DELETE** `/deviceManagement/configurationPolicies/{id}/assignments/{assignmentId}` - - Returns: 204 No Content - - - **Restore Strategy**: DELETE all existing assignments, then POST new ones (atomic via transaction pattern) + - **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"] }` @@ -321,6 +362,11 @@ ### Endpoints to Add (Production-Tested Strategies) - 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`: @@ -331,7 +377,7 @@ ### Graph Contract Updates // Assignments CRUD (standard Graph pattern) 'assignments_list_path' => '/deviceManagement/configurationPolicies/{id}/assignments', - 'assignments_create_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', @@ -348,18 +394,17 @@ ### Graph Contract Updates ## UI Mockups (Wireframe Descriptions) -### Policy View - Assignments Tab +### Policy Version View - Assignments Panel ``` -[General] [Settings] [Assignments] [JSON] +[General] [Settings] [JSON] Assignments (5) ┌─────────────────────────────────────────────────┐ -│ Type │ Name │ Mode │ ID │ +│ Type │ Name │ Filter │ ID │ ├─────────┼───────────────────┼─────────┼─────────┤ -│ Group │ All Users │ Include │ abc-123 │ -│ Group │ Contractors │ Exclude │ def-456 │ -│ User │ john@contoso.com │ Include │ ghi-789 │ +│ Include group │ All Users │ Test (include) │ abc-123 │ +│ Exclude group │ Contractors │ - │ def-456 │ └─────────────────────────────────────────────────┘ Scope Tags (2) @@ -367,18 +412,20 @@ ### Policy View - Assignments Tab • HR-Admins (ID: 123) ``` -### Backup Creation - Checkbox +### Add Policies to Backup Set - Checkboxes ``` -Create Backup Set -───────────────── +Add Policies to Backup Set +───────────────────────── Select Policies: [Settings Catalog: 15 selected] -☑ Include Assignments & Scope Tags - Captures group/user targeting and RBAC scope. - Adds ~2-5 KB per policy with assignments. +☑ Include assignments + Captures include/exclude targeting and filters. -[Cancel] [Create Backup] +☑ Include scope tags + Captures policy scope tag IDs. + +[Cancel] [Add Policies] ``` ### Restore Wizard - Group Mapping Step @@ -408,15 +455,18 @@ ### 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 -- `BackupWithAssignmentsTest`: E2E backup creation with checkbox -- `PolicyViewAssignmentsTabTest`: UI rendering, orphaned IDs +- `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 -- Create backup with/without assignments checkbox +- 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 @@ -426,10 +476,10 @@ ### Manual QA ## Rollout Plan ### Phase 1: Backup with Assignments (MVP) -- Add checkbox to Backup form +- Add checkboxes on Add Policies + Capture Snapshot actions - Fetch assignments from Graph -- Store in `backup_items.assignments` -- Display in Policy View (read-only) +- Store on PolicyVersion (copy assignments to BackupItem) +- Display in Policy Version view (read-only) - **Duration**: ~8-12 hours ### Phase 2: Restore with Group Mapping @@ -440,7 +490,7 @@ ### Phase 2: Restore with Group Mapping ### Phase 3: Scope Tags - Resolve Scope Tag names -- Display in UI +- Display in Policy Version view - Handle restore warnings - **Duration**: ~4-6 hours @@ -461,7 +511,7 @@ ## Risks & Mitigations | Risk | Mitigation | |------|------------| -| Graph API assignments endpoint slow/fails | Async fetch, fail-soft with warning | +| 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 | @@ -470,8 +520,8 @@ ## Risks & Mitigations ## Success Criteria -1. ✅ Backup checkbox functional, assignments captured -2. ✅ Policy View shows assignments tab with accurate data +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 diff --git a/specs/004-assignments-scope-tags/tasks.md b/specs/004-assignments-scope-tags/tasks.md new file mode 100644 index 00000000..e4646088 --- /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-policy-lifecycle/spec.md b/specs/005-policy-lifecycle/spec.md new file mode 100644 index 00000000..873c1dc3 --- /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/tests/Feature/BackupItemReaddTest.php b/tests/Feature/BackupItemReaddTest.php new file mode 100644 index 00000000..b077fd5d --- /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 00000000..2a43ee60 --- /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 46cfc76e..896b6f37 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 00000000..f547b1bf --- /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 f9c8be1c..6099fcb2 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 a183d938..ec698589 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 5661fa25..2d0d171d 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 00000000..eb977a64 --- /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 00000000..f2a2b4b7 --- /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 00000000..23977968 --- /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 00000000..459ef232 --- /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 00000000..f6850676 --- /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 00000000..dc6888e7 --- /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 00000000..e87ffcd7 --- /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 00000000..2611fc95 --- /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 00000000..65352a57 --- /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 00000000..b83ad108 --- /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 5a36be19..74c2c518 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())); });