From c3bdcf4d2daf9198d934a06d48be2b3067283852 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Mon, 22 Dec 2025 20:19:10 +0100 Subject: [PATCH] feat(004): implement PolicyCaptureOrchestrator for assignment consistency BREAKING CHANGE: Assignment capture flow completely refactored Core Changes: - Created PolicyCaptureOrchestrator service for centralized capture coordination - Refactored BackupService to use orchestrator (version-first approach) - Fixed domain model bug: PolicyVersion now stores assignments (source of truth) - BackupItem references PolicyVersion and copies assignments for restore Database: - Added assignments, scope_tags, assignments_hash, scope_tags_hash to policy_versions - Added policy_version_id foreign key to backup_items - Migrations: 2025_12_22_171525, 2025_12_22_171545 Services: - PolicyCaptureOrchestrator: Intelligent version reuse, idempotent backfilling - VersionService: Enhanced to capture assignments during version creation - BackupService: Uses orchestrator, version-first capture flow UI: - Moved assignments widget from Policy to PolicyVersion view - Created PolicyVersionAssignmentsWidget Livewire component - Updated BackupItemsRelationManager columns for new assignment fields Tests: - Deleted BackupWithAssignmentsTest (old behavior) - Created BackupWithAssignmentsConsistencyTest (4 tests, all passing) - Fixed AssignmentFetcherTest and GroupResolverTest for GraphResponse - All 162 tests passing Issue: Assignments/scope tags not displaying in BackupSet items table (UI only) Status: Database contains correct data, UI column definitions need adjustment --- .../BackupItemsRelationManager.php | 24 +- .../PolicyResource/Pages/ViewPolicy.php | 104 ---- .../Pages/ViewPolicyVersion.php | 8 + .../PolicyVersionAssignmentsWidget.php | 23 + app/Models/BackupItem.php | 5 + app/Models/PolicyVersion.php | 2 + app/Services/Intune/BackupService.php | 54 +- .../Intune/PolicyCaptureOrchestrator.php | 288 ++++++++++ app/Services/Intune/VersionService.php | 52 +- database/factories/PolicyVersionFactory.php | 30 + ...525_add_assignments_to_policy_versions.php | 36 ++ ..._add_policy_version_id_to_backup_items.php | 31 + .../view-policy-version-footer.blade.php | 1 + ...olicy-version-assignments-widget.blade.php | 118 ++++ .../BackupWithAssignmentsConsistencyTest.php | 262 +++++++++ tests/Feature/BackupWithAssignmentsTest.php | 533 ------------------ .../PolicyVersionViewAssignmentsTest.php | 74 +++ .../VersionCaptureWithAssignmentsTest.php | 173 ++++++ tests/Unit/AssignmentFetcherTest.php | 125 ++-- tests/Unit/GroupResolverTest.php | 93 ++- 20 files changed, 1254 insertions(+), 782 deletions(-) create mode 100644 app/Livewire/PolicyVersionAssignmentsWidget.php create mode 100644 app/Services/Intune/PolicyCaptureOrchestrator.php create mode 100644 database/factories/PolicyVersionFactory.php create mode 100644 database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php create mode 100644 database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php create mode 100644 resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php create mode 100644 resources/views/livewire/policy-version-assignments-widget.blade.php create mode 100644 tests/Feature/BackupWithAssignmentsConsistencyTest.php delete mode 100644 tests/Feature/BackupWithAssignmentsTest.php create mode 100644 tests/Feature/PolicyVersionViewAssignmentsTest.php create mode 100644 tests/Feature/VersionCaptureWithAssignmentsTest.php diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 2d6a781..a3ee34b 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,24 +47,25 @@ public function table(Table $table): Table ->label('Policy ID') ->copyable(), Tables\Columns\TextColumn::make('platform')->badge(), - Tables\Columns\TextColumn::make('metadata.assignment_count') + Tables\Columns\TextColumn::make('assignments') ->label('Assignments') - ->default('0') ->badge() - ->color('info'), - Tables\Columns\TextColumn::make('metadata.scope_tag_names') + ->color('info') + ->formatStateUsing(fn ($state) => is_array($state) ? count($state) : 0), + Tables\Columns\TextColumn::make('scope_tags') ->label('Scope Tags') ->badge() ->separator(',') ->default('—') - ->formatStateUsing(function ($state) { - if (empty($state)) { - return '—'; + ->formatStateUsing(function ($state, BackupItem $record) { + // Get scope tags from PolicyVersion if available + if ($record->policyVersion && !empty($record->policyVersion->scope_tags)) { + $tags = $record->policyVersion->scope_tags; + if (is_array($tags) && isset($tags['names'])) { + return implode(', ', $tags['names']); + } } - if (is_array($state)) { - return implode(', ', $state); - } - return $state; + return '—'; }), Tables\Columns\TextColumn::make('captured_at')->dateTime(), Tables\Columns\TextColumn::make('created_at')->since(), diff --git a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php index 414c1f0..ad2188f 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php +++ b/app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php @@ -7,9 +7,6 @@ use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; -use Filament\Schemas\Components\Section; -use Filament\Schemas\Components\TextEntry; -use Filament\Schemas\Schema; use Filament\Support\Enums\Width; class ViewPolicy extends ViewRecord @@ -59,105 +56,4 @@ protected function getActions(): array ->color('primary'), ]; } - - public function infolist(Schema $schema): Schema - { - $latestBackupItem = $this->record->backupItems() - ->whereNotNull('assignments') - ->latest('created_at') - ->first(); - - if (! $latestBackupItem || ! $latestBackupItem->hasAssignments()) { - return $schema - ->schema([ - Section::make('Policy Information') - ->schema([ - TextEntry::make('display_name')->label('Name'), - TextEntry::make('policy_type')->label('Type'), - TextEntry::make('platform')->label('Platform'), - TextEntry::make('external_id')->label('Policy ID')->copyable(), - ]), - Section::make('Assignments') - ->schema([ - TextEntry::make('no_assignments') - ->label('') - ->default('No assignments captured yet. Create a backup with "Include Assignments" enabled to view assignment data.') - ->columnSpanFull(), - ]) - ->collapsible(), - ]); - } - - return $schema - ->schema([ - Section::make('Policy Information') - ->schema([ - TextEntry::make('display_name')->label('Name'), - TextEntry::make('policy_type')->label('Type'), - TextEntry::make('platform')->label('Platform'), - TextEntry::make('external_id')->label('Policy ID')->copyable(), - ]), - Section::make('Assignments') - ->description('Captured from backup on '.$latestBackupItem->created_at->format('M d, Y H:i')) - ->schema([ - TextEntry::make('assignment_summary') - ->label('Summary') - ->default(function () use ($latestBackupItem) { - $count = $latestBackupItem->assignment_count; - $orphaned = $latestBackupItem->hasOrphanedAssignments() ? ' (includes orphaned groups)' : ''; - - return "{$count} assignment(s){$orphaned}"; - }), - TextEntry::make('scope_tags') - ->label('Scope Tags') - ->badge() - ->separator(',') - ->default(fn () => $latestBackupItem->scope_tag_names), - TextEntry::make('assignments_detail') - ->label('Assignments') - ->columnSpanFull() - ->default(function () use ($latestBackupItem) { - if (empty($latestBackupItem->assignments)) { - return 'No assignments'; - } - - $lines = []; - foreach ($latestBackupItem->assignments as $assignment) { - $target = $assignment['target'] ?? []; - $type = $target['@odata.type'] ?? 'unknown'; - $intent = $assignment['intent'] ?? 'apply'; - - $typeName = match ($type) { - '#microsoft.graph.groupAssignmentTarget' => 'Group', - '#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users', - '#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices', - default => 'Unknown', - }; - - if ($type === '#microsoft.graph.groupAssignmentTarget') { - $groupId = $target['groupId'] ?? 'unknown'; - $groupName = $this->resolveGroupName($groupId, $latestBackupItem); - $lines[] = "• {$typeName}: {$groupName} ({$intent})"; - } else { - $lines[] = "• {$typeName} ({$intent})"; - } - } - - return implode("\n", $lines); - }) - ->markdown(), - ]) - ->collapsible(), - ]); - } - - private function resolveGroupName(string $groupId, $backupItem): string - { - // Try to find group name in backup metadata or show as orphaned - if ($backupItem->hasOrphanedAssignments()) { - return "⚠️ Unknown Group (ID: {$groupId})"; - } - - return "Group ID: {$groupId}"; - } } diff --git a/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php b/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php index 83ac3d1..291192c 100644 --- a/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php +++ b/app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php @@ -5,10 +5,18 @@ use App\Filament\Resources\PolicyVersionResource; use Filament\Resources\Pages\ViewRecord; use Filament\Support\Enums\Width; +use Illuminate\Contracts\View\View; class ViewPolicyVersion extends ViewRecord { protected static string $resource = PolicyVersionResource::class; protected Width|string|null $maxContentWidth = Width::Full; + + public function getFooter(): ?View + { + return view('filament.resources.policy-version-resource.pages.view-policy-version-footer', [ + 'record' => $this->getRecord(), + ]); + } } diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php new file mode 100644 index 0000000..6211e6c --- /dev/null +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -0,0 +1,23 @@ +version = $version; + } + + public function render() + { + return view('livewire.policy-version-assignments-widget', [ + 'version' => $this->version, + ]); + } +} diff --git a/app/Models/BackupItem.php b/app/Models/BackupItem.php index c4148c3..9f9ad2c 100644 --- a/app/Models/BackupItem.php +++ b/app/Models/BackupItem.php @@ -38,6 +38,11 @@ public function policy(): BelongsTo return $this->belongsTo(Policy::class); } + public function policyVersion(): BelongsTo + { + return $this->belongsTo(PolicyVersion::class); + } + // Assignment helpers public function getAssignmentCountAttribute(): int { diff --git a/app/Models/PolicyVersion.php b/app/Models/PolicyVersion.php index ef8ef6e..ae2555e 100644 --- a/app/Models/PolicyVersion.php +++ b/app/Models/PolicyVersion.php @@ -17,6 +17,8 @@ class PolicyVersion extends Model protected $casts = [ 'snapshot' => 'array', 'metadata' => 'array', + 'assignments' => 'array', + 'scope_tags' => 'array', 'captured_at' => 'datetime', ]; diff --git a/app/Services/Intune/BackupService.php b/app/Services/Intune/BackupService.php index b451d21..72b5ed7 100644 --- a/app/Services/Intune/BackupService.php +++ b/app/Services/Intune/BackupService.php @@ -18,6 +18,7 @@ public function __construct( private readonly SnapshotValidator $snapshotValidator, private readonly PolicySnapshotService $snapshotService, private readonly AssignmentBackupService $assignmentBackupService, + private readonly PolicyCaptureOrchestrator $captureOrchestrator, ) {} /** @@ -232,16 +233,30 @@ private function snapshotPolicy( ?string $actorEmail = null, bool $includeAssignments = false ): array { - $snapshot = $this->snapshotService->fetch($tenant, $policy, $actorEmail); + // Use orchestrator to capture policy + assignments into PolicyVersion first + $captureResult = $this->captureOrchestrator->capture( + policy: $policy, + tenant: $tenant, + includeAssignments: $includeAssignments, + 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']); @@ -255,39 +270,22 @@ private function snapshotPolicy( $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, - ] - ); - - // Enrich with assignments and scope tags if requested - if ($policy->policy_type === 'settingsCatalogPolicy') { - $backupItem = $this->assignmentBackupService->enrichWithAssignments( - backupItem: $backupItem, - tenant: $tenant, - policyId: $policy->external_id, - policyPayload: $payload, - includeAssignments: $includeAssignments - ); - } - return [$backupItem, null]; } diff --git a/app/Services/Intune/PolicyCaptureOrchestrator.php b/app/Services/Intune/PolicyCaptureOrchestrator.php new file mode 100644 index 0000000..685f412 --- /dev/null +++ b/app/Services/Intune/PolicyCaptureOrchestrator.php @@ -0,0 +1,288 @@ + PolicyVersion, 'captured' => array] + */ + public function capture( + Policy $policy, + Tenant $tenant, + bool $includeAssignments = false, + bool $includeScopeTags = false, + ?string $createdBy = null, + array $metadata = [] + ): array { + // 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($tenant->id, $policy->external_id); + + if (!empty($rawAssignments)) { + $assignments = $rawAssignments; + + // Resolve groups for orphaned detection + $groupIds = collect($rawAssignments) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + + if (!empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds); + $captureMetadata['has_orphaned_assignments'] = !empty($resolvedGroups['orphaned']); + } + + $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) { + $scopeTags = [ + 'ids' => $payload['roleScopeTagIds'] ?? ['0'], + 'names' => ['Default'], // Could fetch from Graph if needed + ]; + } + + // 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 && $includeAssignments && is_null($existingVersion->assignments)) { + // Backfill existing version with assignments (idempotent) + $existingVersion->update([ + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, + 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, + ]); + + Log::info('Backfilled existing PolicyVersion with assignments', [ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_id' => $existingVersion->id, + 'version_number' => $existingVersion->version_number, + ]); + + return [ + 'version' => $existingVersion->fresh(), + 'captured' => [ + 'payload' => $payload, + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'metadata' => $captureMetadata, + ], + ]; + } + + if ($existingVersion) { + // Reuse existing version without modification + 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. + * + * @param PolicyVersion $version + * @param Tenant $tenant + * @param Policy $policy + * @param bool $includeAssignments + * @param bool $includeScopeTags + * @return PolicyVersion + */ + public function ensureVersionHasAssignments( + PolicyVersion $version, + Tenant $tenant, + Policy $policy, + bool $includeAssignments = false, + bool $includeScopeTags = false + ): PolicyVersion { + // If version already has assignments, don't overwrite (idempotent) + if ($version->assignments !== 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 ?? []; + + // Fetch assignments + if ($includeAssignments) { + try { + $rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id); + + if (!empty($rawAssignments)) { + $assignments = $rawAssignments; + + // Resolve groups + $groupIds = collect($rawAssignments) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + + if (!empty($groupIds)) { + $resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds); + $metadata['has_orphaned_assignments'] = !empty($resolvedGroups['orphaned']); + } + + $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) { + // Try to get from snapshot + $scopeTags = [ + 'ids' => $version->snapshot['roleScopeTagIds'] ?? ['0'], + 'names' => ['Default'], + ]; + } + + // Update version + $version->update([ + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, + 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, + 'metadata' => $metadata, + ]); + + Log::info('Version backfilled with assignments', [ + 'version_id' => $version->id, + 'has_assignments' => !is_null($assignments), + 'has_scope_tags' => !is_null($scopeTags), + ]); + + return $version->refresh(); + } +} diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 83ab3fa..9597455 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -5,6 +5,8 @@ use App\Models\Policy; use App\Models\PolicyVersion; use App\Models\Tenant; +use App\Services\Graph\AssignmentFetcher; +use App\Services\Graph\GroupResolver; use Carbon\CarbonImmutable; class VersionService @@ -12,6 +14,8 @@ class VersionService public function __construct( private readonly AuditLogger $auditLogger, private readonly PolicySnapshotService $snapshotService, + private readonly AssignmentFetcher $assignmentFetcher, + private readonly GroupResolver $groupResolver, ) {} public function captureVersion( @@ -19,6 +23,8 @@ public function captureVersion( array $payload, ?string $createdBy = null, array $metadata = [], + ?array $assignments = null, + ?array $scopeTags = null, ): PolicyVersion { $versionNumber = $this->nextVersionNumber($policy); @@ -32,6 +38,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( @@ -65,13 +75,53 @@ public function captureFromGraph( throw new \RuntimeException($reason); } - $metadata = array_merge(['source' => 'version_capture'], $metadata); + // Fetch assignments from Graph + $assignments = []; + $scopeTags = []; + $assignmentMetadata = []; + + try { + $rawAssignments = $this->assignmentFetcher->fetch($tenant->id, $policy->external_id); + + if (! empty($rawAssignments)) { + $assignments = $rawAssignments; + + // Resolve groups and scope tags + $groupIds = collect($rawAssignments) + ->pluck('target.groupId') + ->filter() + ->unique() + ->values() + ->toArray(); + + $resolvedGroups = $this->groupResolver->resolve($tenant->id, $groupIds); + + $scopeTags = [ + 'ids' => $policy->roleScopeTagIds ?? ['0'], + 'names' => ['Default'], // Could be fetched from Graph if needed + ]; + + $assignmentMetadata['has_orphaned_assignments'] = ! empty($resolvedGroups['orphaned']); + $assignmentMetadata['assignments_count'] = count($rawAssignments); + } + } catch (\Throwable $e) { + $assignmentMetadata['assignments_fetch_failed'] = true; + $assignmentMetadata['assignments_fetch_error'] = $e->getMessage(); + } + + $metadata = array_merge( + ['source' => 'version_capture'], + $metadata, + $assignmentMetadata + ); return $this->captureVersion( policy: $policy, payload: $snapshot['payload'], createdBy: $createdBy, metadata: $metadata, + assignments: ! empty($assignments) ? $assignments : null, + scopeTags: ! empty($scopeTags) ? $scopeTags : null, ); } diff --git a/database/factories/PolicyVersionFactory.php b/database/factories/PolicyVersionFactory.php new file mode 100644 index 0000000..fe87d32 --- /dev/null +++ b/database/factories/PolicyVersionFactory.php @@ -0,0 +1,30 @@ + + */ +class PolicyVersionFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => \App\Models\Tenant::factory(), + 'policy_id' => \App\Models\Policy::factory(), + 'version_number' => 1, + 'policy_type' => 'deviceManagementConfigurationPolicy', + 'platform' => 'windows10', + 'snapshot' => ['test' => 'data'], + 'metadata' => [], + 'captured_at' => now(), + ]; + } +} diff --git a/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php b/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php new file mode 100644 index 0000000..efc7a04 --- /dev/null +++ b/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php @@ -0,0 +1,36 @@ +json('assignments')->nullable()->after('metadata'); + $table->json('scope_tags')->nullable()->after('assignments'); + $table->string('assignments_hash', 64)->nullable()->after('scope_tags'); + $table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash'); + + $table->index('assignments_hash'); + $table->index('scope_tags_hash'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('policy_versions', function (Blueprint $table) { + $table->dropIndex(['assignments_hash']); + $table->dropIndex(['scope_tags_hash']); + $table->dropColumn(['assignments', 'scope_tags', 'assignments_hash', 'scope_tags_hash']); + }); + } +}; diff --git a/database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php b/database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php new file mode 100644 index 0000000..c92f20e --- /dev/null +++ b/database/migrations/2025_12_22_171545_add_policy_version_id_to_backup_items.php @@ -0,0 +1,31 @@ +foreignId('policy_version_id')->nullable()->after('policy_id')->constrained('policy_versions')->nullOnDelete(); + $table->index('policy_version_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('backup_items', function (Blueprint $table) { + $table->dropForeign(['policy_version_id']); + $table->dropIndex(['policy_version_id']); + $table->dropColumn('policy_version_id'); + }); + } +}; diff --git a/resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php b/resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php new file mode 100644 index 0000000..8205dfd --- /dev/null +++ b/resources/views/filament/resources/policy-version-resource/pages/view-policy-version-footer.blade.php @@ -0,0 +1 @@ +@livewire('policy-version-assignments-widget', ['version' => $record]) diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php new file mode 100644 index 0000000..44ecdeb --- /dev/null +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -0,0 +1,118 @@ +
+ @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'] ?? 'unknown'; + $intent = $assignment['intent'] ?? 'apply'; + + $typeName = match($type) { + '#microsoft.graph.groupAssignmentTarget' => 'Group', + '#microsoft.graph.allLicensedUsersAssignmentTarget' => 'All Users', + '#microsoft.graph.allDevicesAssignmentTarget' => 'All Devices', + default => 'Unknown' + }; + + $groupId = $target['groupId'] ?? null; + $hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false; + @endphp + +
+ + {{ $typeName }} + + @if($groupId) + : + @if($hasOrphaned) + + ⚠️ Unknown Group (ID: {{ $groupId }}) + + @else + + Group ID: {{ $groupId }} + + @endif + @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/tests/Feature/BackupWithAssignmentsConsistencyTest.php b/tests/Feature/BackupWithAssignmentsConsistencyTest.php new file mode 100644 index 0000000..37c6d2b --- /dev/null +++ b/tests/Feature/BackupWithAssignmentsConsistencyTest.php @@ -0,0 +1,262 @@ +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('resolve') + ->andReturn([ + 'resolved' => ['group-123' => 'Test Group'], + 'orphaned' => [], + ]); + }); +}); + +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/BackupWithAssignmentsTest.php b/tests/Feature/BackupWithAssignmentsTest.php deleted file mode 100644 index e9e2ca5..0000000 --- a/tests/Feature/BackupWithAssignmentsTest.php +++ /dev/null @@ -1,533 +0,0 @@ -tenant = Tenant::factory()->create([ - 'tenant_id' => 'tenant-123', - 'status' => 'active', - ]); - - $this->user = User::factory()->create(); - - $this->policy = Policy::factory()->create([ - 'tenant_id' => $this->tenant->id, - 'external_id' => 'policy-456', - 'policy_type' => 'settingsCatalogPolicy', - 'platform' => 'windows10', - ]); - - $this->tenant->makeCurrent(); -}); - -test('creates backup with assignments when checkbox enabled', function () { - // Mock PolicySnapshotService to return fake payload - $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->andReturn([ - 'payload' => [ - 'id' => 'policy-456', - 'name' => 'Test Policy', - 'roleScopeTagIds' => ['0', '123'], - 'settings' => [], - ], - 'metadata' => [], - 'warnings' => [], - ]); - }); - - // Mock AssignmentFetcher - $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->with('tenant-123', 'policy-456') - ->andReturn([ - [ - 'id' => 'assignment-1', - 'target' => [ - '@odata.type' => '#microsoft.graph.groupAssignmentTarget', - 'groupId' => 'group-abc', - ], - 'intent' => 'apply', - ], - [ - 'id' => 'assignment-2', - 'target' => [ - '@odata.type' => '#microsoft.graph.groupAssignmentTarget', - 'groupId' => 'group-def', - ], - 'intent' => 'apply', - ], - ]); - }); - - // Mock GroupResolver - $this->mock(GroupResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolveGroupIds') - ->once() - ->with(['group-abc', 'group-def'], 'tenant-123') - ->andReturn([ - 'group-abc' => [ - 'id' => 'group-abc', - 'displayName' => 'All Users', - 'orphaned' => false, - ], - 'group-def' => [ - 'id' => 'group-def', - 'displayName' => 'IT Department', - 'orphaned' => false, - ], - ]); - }); - - // Mock ScopeTagResolver - $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolve') - ->once() - ->with(['0', '123'], Mockery::type(Tenant::class)) - ->andReturn([ - ['id' => '0', 'displayName' => 'Default'], - ['id' => '123', 'displayName' => 'HR-Admins'], - ]); - }); - - /** @var BackupService $backupService */ - $backupService = app(BackupService::class); - - $backupSet = $backupService->createBackupSet( - tenant: $this->tenant, - policyIds: [$this->policy->id], - actorEmail: $this->user->email, - actorName: $this->user->name, - name: 'Test Backup with Assignments', - includeAssignments: true - ); - - expect($backupSet)->toBeInstanceOf(BackupSet::class) - ->and($backupSet->status)->toBe('completed') - ->and($backupSet->item_count)->toBe(1); - - $backupItem = $backupSet->items()->first(); - - expect($backupItem)->toBeInstanceOf(BackupItem::class) - ->and($backupItem->assignments)->toBeArray() - ->and($backupItem->assignments)->toHaveCount(2) - ->and($backupItem->metadata['assignment_count'])->toBe(2) - ->and($backupItem->metadata['scope_tag_ids'])->toBe(['0', '123']) - ->and($backupItem->metadata['scope_tag_names'])->toBe(['Default', 'HR-Admins']) - ->and($backupItem->metadata['has_orphaned_assignments'])->toBeFalse() - ->and($backupItem->metadata['assignments_fetch_failed'] ?? false)->toBeFalse(); - - // Verify audit log - $this->assertDatabaseHas('audit_logs', [ - 'tenant_id' => $this->tenant->id, - 'action' => 'backup.created', - 'resource_type' => 'backup_set', - 'resource_id' => (string) $backupSet->id, - 'status' => 'success', - ]); -}); - -test('creates backup without assignments when checkbox disabled', function () { - // Mock PolicySnapshotService - $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->andReturn([ - 'payload' => [ - 'id' => 'policy-456', - 'name' => 'Test Policy', - 'roleScopeTagIds' => ['0', '123'], - 'settings' => [], - ], - 'metadata' => [], - 'warnings' => [], - ]); - }); - - // AssignmentFetcher should NOT be called - $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch')->never(); - }); - - // GroupResolver should NOT be called for assignments - $this->mock(GroupResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolveGroupIds')->never(); - }); - - // ScopeTagResolver should still be called for scope tags - $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolve') - ->once() - ->with(['0', '123'], Mockery::type(Tenant::class)) - ->andReturn([ - ['id' => '0', 'displayName' => 'Default'], - ['id' => '123', 'displayName' => 'HR-Admins'], - ]); - }); - - /** @var BackupService $backupService */ - $backupService = app(BackupService::class); - - $backupSet = $backupService->createBackupSet( - tenant: $this->tenant, - policyIds: [$this->policy->id], - actorEmail: $this->user->email, - actorName: $this->user->name, - name: 'Test Backup without Assignments', - includeAssignments: false - ); - - expect($backupSet)->toBeInstanceOf(BackupSet::class) - ->and($backupSet->status)->toBe('completed') - ->and($backupSet->item_count)->toBe(1); - - $backupItem = $backupSet->items()->first(); - - expect($backupItem)->toBeInstanceOf(BackupItem::class) - ->and($backupItem->assignments)->toBeNull() - ->and($backupItem->metadata['assignment_count'] ?? 0)->toBe(0) - ->and($backupItem->metadata['scope_tag_ids'])->toBe(['0', '123']) - ->and($backupItem->metadata['scope_tag_names'])->toBe(['Default', 'HR-Admins']); -}); - -test('handles fetch failure gracefully', function () { - // Mock PolicySnapshotService - $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->andReturn([ - 'payload' => [ - 'id' => 'policy-456', - 'name' => 'Test Policy', - 'roleScopeTagIds' => ['0', '123'], - 'settings' => [], - ], - 'metadata' => [], - 'warnings' => [], - ]); - }); - - // Mock AssignmentFetcher to throw exception - $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->with('tenant-123', 'policy-456') - ->andReturn([]); // Returns empty array on failure (fail-soft) - }); - - // Mock GroupResolver (won't be called if assignments empty) - $this->mock(GroupResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolveGroupIds')->never(); - }); - - // Mock ScopeTagResolver - $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolve') - ->once() - ->with(['0', '123'], Mockery::type(Tenant::class)) - ->andReturn([ - ['id' => '0', 'displayName' => 'Default'], - ['id' => '123', 'displayName' => 'HR-Admins'], - ]); - }); - - /** @var BackupService $backupService */ - $backupService = app(BackupService::class); - - $backupSet = $backupService->createBackupSet( - tenant: $this->tenant, - policyIds: [$this->policy->id], - actorEmail: $this->user->email, - actorName: $this->user->name, - name: 'Test Backup with Fetch Failure', - includeAssignments: true - ); - - // Backup should still complete (fail-soft) - expect($backupSet)->toBeInstanceOf(BackupSet::class) - ->and($backupSet->status)->toBe('completed') - ->and($backupSet->item_count)->toBe(1); - - $backupItem = $backupSet->items()->first(); - - expect($backupItem)->toBeInstanceOf(BackupItem::class) - ->and($backupItem->assignments)->toBeArray() - ->and($backupItem->assignments)->toBeEmpty() - ->and($backupItem->metadata['assignment_count'])->toBe(0); -}); - -test('detects orphaned groups', function () { - // Mock PolicySnapshotService - $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->andReturn([ - 'payload' => [ - 'id' => 'policy-456', - 'name' => 'Test Policy', - 'roleScopeTagIds' => ['0', '123'], - 'settings' => [], - ], - 'metadata' => [], - 'warnings' => [], - ]); - }); - - // Mock AssignmentFetcher - $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->with('tenant-123', 'policy-456') - ->andReturn([ - [ - 'id' => 'assignment-1', - 'target' => [ - '@odata.type' => '#microsoft.graph.groupAssignmentTarget', - 'groupId' => 'group-abc', - ], - 'intent' => 'apply', - ], - [ - 'id' => 'assignment-2', - 'target' => [ - '@odata.type' => '#microsoft.graph.groupAssignmentTarget', - 'groupId' => 'group-orphaned', - ], - 'intent' => 'apply', - ], - ]); - }); - - // Mock GroupResolver with orphaned group - $this->mock(GroupResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolveGroupIds') - ->once() - ->with(['group-abc', 'group-orphaned'], 'tenant-123') - ->andReturn([ - 'group-abc' => [ - 'id' => 'group-abc', - 'displayName' => 'All Users', - 'orphaned' => false, - ], - 'group-orphaned' => [ - 'id' => 'group-orphaned', - 'displayName' => null, - 'orphaned' => true, - ], - ]); - }); - - // Mock ScopeTagResolver - $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolve') - ->once() - ->with(['0', '123'], Mockery::type(Tenant::class)) - ->andReturn([ - ['id' => '0', 'displayName' => 'Default'], - ['id' => '123', 'displayName' => 'HR-Admins'], - ]); - }); - - /** @var BackupService $backupService */ - $backupService = app(BackupService::class); - - $backupSet = $backupService->createBackupSet( - tenant: $this->tenant, - policyIds: [$this->policy->id], - actorEmail: $this->user->email, - actorName: $this->user->name, - name: 'Test Backup with Orphaned Groups', - includeAssignments: true - ); - - $backupItem = $backupSet->items()->first(); - - expect($backupItem->metadata['has_orphaned_assignments'])->toBeTrue() - ->and($backupItem->metadata['assignment_count'])->toBe(2); -}); - -test('adds policies to existing backup set with assignments', function () { - // Create an existing backup set without assignments - $backupSet = BackupSet::factory()->create([ - 'tenant_id' => $this->tenant->id, - 'name' => 'Existing Backup', - 'status' => 'completed', - 'item_count' => 0, - ]); - - // Create a second policy to add - $secondPolicy = Policy::factory()->create([ - 'tenant_id' => $this->tenant->id, - 'external_id' => 'policy-789', - 'policy_type' => 'settingsCatalogPolicy', - 'platform' => 'windows10', - ]); - - // Mock PolicySnapshotService - $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->andReturn([ - 'payload' => [ - 'id' => 'policy-789', - 'name' => 'Second Policy', - 'roleScopeTagIds' => ['0'], - 'settings' => [], - ], - 'metadata' => [], - 'warnings' => [], - ]); - }); - - // Mock AssignmentFetcher - $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->with('tenant-123', 'policy-789') - ->andReturn([ - [ - 'id' => 'assignment-3', - 'target' => [ - '@odata.type' => '#microsoft.graph.groupAssignmentTarget', - 'groupId' => 'group-xyz', - ], - 'intent' => 'apply', - ], - ]); - }); - - // Mock GroupResolver - $this->mock(GroupResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolveGroupIds') - ->once() - ->with(['group-xyz'], 'tenant-123') - ->andReturn([ - 'group-xyz' => [ - 'id' => 'group-xyz', - 'displayName' => 'Test Group', - 'orphaned' => false, - ], - ]); - }); - - // Mock ScopeTagResolver - $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolve') - ->once() - ->with(['0'], Mockery::type(Tenant::class)) - ->andReturn([ - ['id' => '0', 'displayName' => 'Default'], - ]); - }); - - /** @var BackupService $backupService */ - $backupService = app(BackupService::class); - - $updatedBackupSet = $backupService->addPoliciesToSet( - tenant: $this->tenant, - backupSet: $backupSet, - policyIds: [$secondPolicy->id], - actorEmail: $this->user->email, - actorName: $this->user->name, - includeAssignments: true - ); - - expect($updatedBackupSet->item_count)->toBe(1); - - $backupItem = $updatedBackupSet->items()->first(); - - expect($backupItem)->toBeInstanceOf(BackupItem::class) - ->and($backupItem->assignments)->toBeArray() - ->and($backupItem->assignments)->toHaveCount(1) - ->and($backupItem->metadata['assignment_count'])->toBe(1) - ->and($backupItem->metadata['scope_tag_ids'])->toBe(['0']) - ->and($backupItem->metadata['scope_tag_names'])->toBe(['Default']) - ->and($backupItem->metadata['has_orphaned_assignments'])->toBeFalse(); -}); - -test('adds policies to existing backup set without assignments when flag is false', function () { - // Create an existing backup set - $backupSet = BackupSet::factory()->create([ - 'tenant_id' => $this->tenant->id, - 'name' => 'Existing Backup', - 'status' => 'completed', - 'item_count' => 0, - ]); - - // Create a second policy - $secondPolicy = Policy::factory()->create([ - 'tenant_id' => $this->tenant->id, - 'external_id' => 'policy-999', - 'policy_type' => 'settingsCatalogPolicy', - 'platform' => 'windows10', - ]); - - // Mock PolicySnapshotService - $this->mock(PolicySnapshotService::class, function (MockInterface $mock) { - $mock->shouldReceive('fetch') - ->once() - ->andReturn([ - 'payload' => [ - 'id' => 'policy-999', - 'name' => 'Third Policy', - 'roleScopeTagIds' => ['0'], - 'settings' => [], - ], - 'metadata' => [], - 'warnings' => [], - ]); - }); - - // AssignmentFetcher should NOT be called when includeAssignments is false - $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { - $mock->shouldNotReceive('fetch'); - }); - - // Mock ScopeTagResolver (still called for scope tags in policy payload) - $this->mock(ScopeTagResolver::class, function (MockInterface $mock) { - $mock->shouldReceive('resolve') - ->once() - ->with(['0'], Mockery::type(Tenant::class)) - ->andReturn([ - ['id' => '0', 'displayName' => 'Default'], - ]); - }); - - /** @var BackupService $backupService */ - $backupService = app(BackupService::class); - - $updatedBackupSet = $backupService->addPoliciesToSet( - tenant: $this->tenant, - backupSet: $backupSet, - policyIds: [$secondPolicy->id], - actorEmail: $this->user->email, - actorName: $this->user->name, - includeAssignments: false - ); - - expect($updatedBackupSet->item_count)->toBe(1); - - $backupItem = $updatedBackupSet->items()->first(); - - expect($backupItem)->toBeInstanceOf(BackupItem::class) - ->and($backupItem->assignments)->toBeNull() - ->and($backupItem->metadata['assignment_count'])->toBe(0) - ->and($backupItem->metadata['scope_tag_ids'])->toBe(['0']) - ->and($backupItem->metadata['scope_tag_names'])->toBe(['Default']); -}); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php new file mode 100644 index 0000000..a6b1779 --- /dev/null +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -0,0 +1,74 @@ +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', + ], + ], + ], + '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('1 assignment(s)'); +}); + +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/VersionCaptureWithAssignmentsTest.php b/tests/Feature/VersionCaptureWithAssignmentsTest.php new file mode 100644 index 0000000..416e2a2 --- /dev/null +++ b/tests/Feature/VersionCaptureWithAssignmentsTest.php @@ -0,0 +1,173 @@ +tenant = Tenant::factory()->create(); + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'external_id' => 'test-policy-id', + ]); +}); + +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', + ], + ], + ]); + }); + + $this->mock(GroupResolver::class, function ($mock) { + $mock->shouldReceive('resolve') + ->once() + ->andReturn([ + 'resolved' => [ + 'group-123' => ['id' => 'group-123', 'displayName' => 'Test Group'], + ], + 'orphaned' => [], + ]); + }); + + $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(); +}); + +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 index 97ec71f..f685067 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -2,7 +2,7 @@ use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\GraphException; -use App\Services\Graph\GraphLogger; +use App\Services\Graph\GraphResponse; use App\Services\Graph\MicrosoftGraphClient; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -11,8 +11,7 @@ beforeEach(function () { $this->graphClient = Mockery::mock(MicrosoftGraphClient::class); - $this->logger = Mockery::mock(GraphLogger::class); - $this->fetcher = new AssignmentFetcher($this->graphClient, $this->logger); + $this->fetcher = new AssignmentFetcher($this->graphClient); }); test('primary endpoint success', function () { @@ -23,16 +22,18 @@ ['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']], ]; - $this->graphClient - ->shouldReceive('get') - ->once() - ->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId) - ->andReturn(['value' => $assignments]); + $response = new GraphResponse( + success: true, + data: ['value' => $assignments] + ); - $this->logger - ->shouldReceive('logDebug') + $this->graphClient + ->shouldReceive('request') ->once() - ->with('Fetched assignments via primary endpoint', Mockery::any()); + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($response); $result = $this->fetcher->fetch($tenantId, $policyId); @@ -47,25 +48,36 @@ ]; // Primary returns empty + $primaryResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); + $this->graphClient - ->shouldReceive('get') + ->shouldReceive('request') ->once() - ->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId) - ->andReturn(['value' => []]); + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [ + 'tenant' => $tenantId, + ]) + ->andReturn($primaryResponse); // Fallback returns assignments - $this->graphClient - ->shouldReceive('get') - ->once() - ->with('/deviceManagement/configurationPolicies', $tenantId, [ - '$expand' => 'assignments', - '$filter' => "id eq '{$policyId}'", - ]) - ->andReturn(['value' => [['id' => $policyId, 'assignments' => $assignments]]]); + $fallbackResponse = new GraphResponse( + success: true, + data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]] + ); - $this->logger - ->shouldReceive('logDebug') - ->twice(); + $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); @@ -77,19 +89,10 @@ $policyId = 'policy-456'; $this->graphClient - ->shouldReceive('get') + ->shouldReceive('request') ->once() ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); - $this->logger - ->shouldReceive('logWarning') - ->once() - ->with('Failed to fetch assignments', Mockery::on(function ($context) use ($tenantId, $policyId) { - return $context['tenant_id'] === $tenantId - && $context['policy_id'] === $policyId - && isset($context['context']['request_id']); - })); - $result = $this->fetcher->fetch($tenantId, $policyId); expect($result)->toBe([]); @@ -100,22 +103,28 @@ $policyId = 'policy-456'; // Primary returns empty + $primaryResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); + $this->graphClient - ->shouldReceive('get') + ->shouldReceive('request') ->once() - ->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId) - ->andReturn(['value' => []]); + ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any()) + ->andReturn($primaryResponse); // Fallback returns empty - $this->graphClient - ->shouldReceive('get') - ->once() - ->with('/deviceManagement/configurationPolicies', $tenantId, Mockery::any()) - ->andReturn(['value' => []]); + $fallbackResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); - $this->logger - ->shouldReceive('logDebug') - ->times(2); + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('GET', '/deviceManagement/configurationPolicies', Mockery::any()) + ->andReturn($fallbackResponse); $result = $this->fetcher->fetch($tenantId, $policyId); @@ -127,20 +136,26 @@ $policyId = 'policy-456'; // Primary returns empty + $primaryResponse = new GraphResponse( + success: true, + data: ['value' => []] + ); + $this->graphClient - ->shouldReceive('get') + ->shouldReceive('request') ->once() - ->andReturn(['value' => []]); + ->andReturn($primaryResponse); // Fallback returns policy without assignments key - $this->graphClient - ->shouldReceive('get') - ->once() - ->andReturn(['value' => [['id' => $policyId]]]); + $fallbackResponse = new GraphResponse( + success: true, + data: ['value' => [['id' => $policyId]]] + ); - $this->logger - ->shouldReceive('logDebug') - ->times(2); + $this->graphClient + ->shouldReceive('request') + ->once() + ->andReturn($fallbackResponse); $result = $this->fetcher->fetch($tenantId, $policyId); diff --git a/tests/Unit/GroupResolverTest.php b/tests/Unit/GroupResolverTest.php index 0b19355..2611fc9 100644 --- a/tests/Unit/GroupResolverTest.php +++ b/tests/Unit/GroupResolverTest.php @@ -1,7 +1,7 @@ graphClient = Mockery::mock(MicrosoftGraphClient::class); - $this->logger = Mockery::mock(GraphLogger::class); - $this->resolver = new GroupResolver($this->graphClient, $this->logger); + $this->resolver = new GroupResolver($this->graphClient); }); test('resolves all groups', function () { $tenantId = 'tenant-123'; $groupIds = ['group-1', 'group-2', 'group-3']; - $graphResponse = [ + $graphData = [ 'value' => [ ['id' => 'group-1', 'displayName' => 'All Users'], ['id' => 'group-2', 'displayName' => 'HR Team'], @@ -28,18 +27,22 @@ ], ]; - $this->graphClient - ->shouldReceive('post') - ->once() - ->with('/directoryObjects/getByIds', [ - 'ids' => $groupIds, - 'types' => ['group'], - ], $tenantId) - ->andReturn($graphResponse); + $response = new GraphResponse( + success: true, + data: $graphData + ); - $this->logger - ->shouldReceive('logDebug') - ->once(); + $this->graphClient + ->shouldReceive('request') + ->once() + ->with('POST', '/directoryObjects/getByIds', [ + 'tenant' => $tenantId, + 'json' => [ + 'ids' => $groupIds, + 'types' => ['group'], + ], + ]) + ->andReturn($response); $result = $this->resolver->resolveGroupIds($groupIds, $tenantId); @@ -58,26 +61,22 @@ test('handles orphaned ids', function () { $tenantId = 'tenant-123'; $groupIds = ['group-1', 'group-2', 'group-3']; - $graphResponse = [ + $graphData = [ 'value' => [ ['id' => 'group-1', 'displayName' => 'All Users'], // group-2 and group-3 are missing (deleted) ], ]; - $this->graphClient - ->shouldReceive('post') - ->once() - ->andReturn($graphResponse); + $response = new GraphResponse( + success: true, + data: $graphData + ); - $this->logger - ->shouldReceive('logDebug') + $this->graphClient + ->shouldReceive('request') ->once() - ->with('Resolved group IDs', Mockery::on(function ($context) { - return $context['requested'] === 3 - && $context['resolved'] === 1 - && $context['orphaned'] === 2; - })); + ->andReturn($response); $result = $this->resolver->resolveGroupIds($groupIds, $tenantId); @@ -96,22 +95,23 @@ test('caches results', function () { $tenantId = 'tenant-123'; $groupIds = ['group-1', 'group-2']; - $graphResponse = [ + $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('post') + ->shouldReceive('request') ->once() - ->andReturn($graphResponse); - - $this->logger - ->shouldReceive('logDebug') - ->once(); + ->andReturn($response); $result1 = $this->resolver->resolveGroupIds($groupIds, $tenantId); @@ -133,18 +133,10 @@ $groupIds = ['group-1', 'group-2']; $this->graphClient - ->shouldReceive('post') + ->shouldReceive('request') ->once() ->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); - $this->logger - ->shouldReceive('logWarning') - ->once() - ->with('Failed to resolve group IDs', Mockery::on(function ($context) use ($groupIds) { - return $context['group_ids'] === $groupIds - && isset($context['context']['request_id']); - })); - $result = $this->resolver->resolveGroupIds($groupIds, $tenantId); // All groups should be marked as orphaned on failure @@ -159,7 +151,7 @@ $tenantId = 'tenant-123'; $groupIds1 = ['group-1', 'group-2', 'group-3']; $groupIds2 = ['group-3', 'group-1', 'group-2']; // Different order - $graphResponse = [ + $graphData = [ 'value' => [ ['id' => 'group-1', 'displayName' => 'All Users'], ['id' => 'group-2', 'displayName' => 'HR Team'], @@ -167,15 +159,16 @@ ], ]; + $response = new GraphResponse( + success: true, + data: $graphData + ); + // First call with groupIds1 $this->graphClient - ->shouldReceive('post') + ->shouldReceive('request') ->once() - ->andReturn($graphResponse); - - $this->logger - ->shouldReceive('logDebug') - ->once(); + ->andReturn($response); $result1 = $this->resolver->resolveGroupIds($groupIds1, $tenantId);