feat/004-assignments-scope-tags #4

Merged
ahmido merged 41 commits from feat/004-assignments-scope-tags into dev 2025-12-23 21:49:59 +00:00
20 changed files with 1254 additions and 782 deletions
Showing only changes of commit c3bdcf4d2d - Show all commits

View File

@ -23,6 +23,7 @@ class BackupItemsRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('policyVersion'))
->columns([ ->columns([
Tables\Columns\TextColumn::make('policy.display_name') Tables\Columns\TextColumn::make('policy.display_name')
->label('Policy') ->label('Policy')
@ -46,24 +47,25 @@ public function table(Table $table): Table
->label('Policy ID') ->label('Policy ID')
->copyable(), ->copyable(),
Tables\Columns\TextColumn::make('platform')->badge(), Tables\Columns\TextColumn::make('platform')->badge(),
Tables\Columns\TextColumn::make('metadata.assignment_count') Tables\Columns\TextColumn::make('assignments')
->label('Assignments') ->label('Assignments')
->default('0')
->badge() ->badge()
->color('info'), ->color('info')
Tables\Columns\TextColumn::make('metadata.scope_tag_names') ->formatStateUsing(fn ($state) => is_array($state) ? count($state) : 0),
Tables\Columns\TextColumn::make('scope_tags')
->label('Scope Tags') ->label('Scope Tags')
->badge() ->badge()
->separator(',') ->separator(',')
->default('—') ->default('—')
->formatStateUsing(function ($state) { ->formatStateUsing(function ($state, BackupItem $record) {
if (empty($state)) { // Get scope tags from PolicyVersion if available
return '—'; 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 '—';
return implode(', ', $state);
}
return $state;
}), }),
Tables\Columns\TextColumn::make('captured_at')->dateTime(), Tables\Columns\TextColumn::make('captured_at')->dateTime(),
Tables\Columns\TextColumn::make('created_at')->since(), Tables\Columns\TextColumn::make('created_at')->since(),

View File

@ -7,9 +7,6 @@
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\TextEntry;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
class ViewPolicy extends ViewRecord class ViewPolicy extends ViewRecord
@ -59,105 +56,4 @@ protected function getActions(): array
->color('primary'), ->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}";
}
} }

View File

@ -5,10 +5,18 @@
use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\PolicyVersionResource;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\Width; use Filament\Support\Enums\Width;
use Illuminate\Contracts\View\View;
class ViewPolicyVersion extends ViewRecord class ViewPolicyVersion extends ViewRecord
{ {
protected static string $resource = PolicyVersionResource::class; protected static string $resource = PolicyVersionResource::class;
protected Width|string|null $maxContentWidth = Width::Full; 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(),
]);
}
} }

View File

@ -0,0 +1,23 @@
<?php
namespace App\Livewire;
use App\Models\PolicyVersion;
use Livewire\Component;
class PolicyVersionAssignmentsWidget extends Component
{
public PolicyVersion $version;
public function mount(PolicyVersion $version): void
{
$this->version = $version;
}
public function render()
{
return view('livewire.policy-version-assignments-widget', [
'version' => $this->version,
]);
}
}

View File

@ -38,6 +38,11 @@ public function policy(): BelongsTo
return $this->belongsTo(Policy::class); return $this->belongsTo(Policy::class);
} }
public function policyVersion(): BelongsTo
{
return $this->belongsTo(PolicyVersion::class);
}
// Assignment helpers // Assignment helpers
public function getAssignmentCountAttribute(): int public function getAssignmentCountAttribute(): int
{ {

View File

@ -17,6 +17,8 @@ class PolicyVersion extends Model
protected $casts = [ protected $casts = [
'snapshot' => 'array', 'snapshot' => 'array',
'metadata' => 'array', 'metadata' => 'array',
'assignments' => 'array',
'scope_tags' => 'array',
'captured_at' => 'datetime', 'captured_at' => 'datetime',
]; ];

View File

@ -18,6 +18,7 @@ public function __construct(
private readonly SnapshotValidator $snapshotValidator, private readonly SnapshotValidator $snapshotValidator,
private readonly PolicySnapshotService $snapshotService, private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentBackupService $assignmentBackupService, private readonly AssignmentBackupService $assignmentBackupService,
private readonly PolicyCaptureOrchestrator $captureOrchestrator,
) {} ) {}
/** /**
@ -232,16 +233,30 @@ private function snapshotPolicy(
?string $actorEmail = null, ?string $actorEmail = null,
bool $includeAssignments = false bool $includeAssignments = false
): array { ): 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'])) { // Check for capture failure
return [null, $snapshot['failure']]; if (isset($captureResult['failure'])) {
return [null, $captureResult['failure']];
} }
$payload = $snapshot['payload']; $version = $captureResult['version'];
$metadata = $snapshot['metadata'] ?? []; $captured = $captureResult['captured'];
$metadataWarnings = $snapshot['warnings'] ?? []; $payload = $captured['payload'];
$metadata = $captured['metadata'] ?? [];
$metadataWarnings = $captured['warnings'] ?? [];
// Validate snapshot
$validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []); $validation = $this->snapshotValidator->validate(is_array($payload) ? $payload : []);
$metadataWarnings = array_merge($metadataWarnings, $validation['warnings']); $metadataWarnings = array_merge($metadataWarnings, $validation['warnings']);
@ -255,39 +270,22 @@ private function snapshotPolicy(
$metadata['warnings'] = array_values(array_unique($metadataWarnings)); $metadata['warnings'] = array_values(array_unique($metadataWarnings));
} }
// Create BackupItem as a copy/reference of the PolicyVersion
$backupItem = BackupItem::create([ $backupItem = BackupItem::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'backup_set_id' => $backupSet->id, 'backup_set_id' => $backupSet->id,
'policy_id' => $policy->id, 'policy_id' => $policy->id,
'policy_version_id' => $version->id, // Link to version
'policy_identifier' => $policy->external_id, 'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type, 'policy_type' => $policy->policy_type,
'platform' => $policy->platform, 'platform' => $policy->platform,
'payload' => $payload, 'payload' => $payload,
'metadata' => $metadata, '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]; return [$backupItem, null];
} }

View File

@ -0,0 +1,288 @@
<?php
namespace App\Services\Intune;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GroupResolver;
use Illuminate\Support\Facades\Log;
/**
* Orchestrates policy capture with assignments and scope tags.
*
* Ensures PolicyVersion is the source of truth, with BackupItem as restore copy.
*/
class PolicyCaptureOrchestrator
{
public function __construct(
private readonly VersionService $versionService,
private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
) {}
/**
* Capture policy snapshot with optional assignments/scope tags.
*
* @param Policy $policy
* @param Tenant $tenant
* @param bool $includeAssignments
* @param bool $includeScopeTags
* @param string|null $createdBy
* @param array $metadata
* @return array ['version' => 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();
}
}

View File

@ -5,6 +5,8 @@
use App\Models\Policy; use App\Models\Policy;
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GroupResolver;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
class VersionService class VersionService
@ -12,6 +14,8 @@ class VersionService
public function __construct( public function __construct(
private readonly AuditLogger $auditLogger, private readonly AuditLogger $auditLogger,
private readonly PolicySnapshotService $snapshotService, private readonly PolicySnapshotService $snapshotService,
private readonly AssignmentFetcher $assignmentFetcher,
private readonly GroupResolver $groupResolver,
) {} ) {}
public function captureVersion( public function captureVersion(
@ -19,6 +23,8 @@ public function captureVersion(
array $payload, array $payload,
?string $createdBy = null, ?string $createdBy = null,
array $metadata = [], array $metadata = [],
?array $assignments = null,
?array $scopeTags = null,
): PolicyVersion { ): PolicyVersion {
$versionNumber = $this->nextVersionNumber($policy); $versionNumber = $this->nextVersionNumber($policy);
@ -32,6 +38,10 @@ public function captureVersion(
'captured_at' => CarbonImmutable::now(), 'captured_at' => CarbonImmutable::now(),
'snapshot' => $payload, 'snapshot' => $payload,
'metadata' => $metadata, '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( $this->auditLogger->log(
@ -65,13 +75,53 @@ public function captureFromGraph(
throw new \RuntimeException($reason); 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( return $this->captureVersion(
policy: $policy, policy: $policy,
payload: $snapshot['payload'], payload: $snapshot['payload'],
createdBy: $createdBy, createdBy: $createdBy,
metadata: $metadata, metadata: $metadata,
assignments: ! empty($assignments) ? $assignments : null,
scopeTags: ! empty($scopeTags) ? $scopeTags : null,
); );
} }

View File

@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PolicyVersion>
*/
class PolicyVersionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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(),
];
}
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
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');
});
}
/**
* 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']);
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('backup_items', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1 @@
@livewire('policy-version-assignments-widget', ['version' => $record])

View File

@ -0,0 +1,118 @@
<div class="fi-section">
@if($version->assignments && count($version->assignments) > 0)
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Captured with this version on {{ $version->captured_at->format('M d, Y H:i') }}
</p>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 dark:border-white/10">
<!-- Summary -->
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Summary</h4>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{{ count($version->assignments) }} assignment(s)
@php
$hasOrphaned = $version->metadata['has_orphaned_assignments'] ?? false;
@endphp
@if($hasOrphaned)
<span class="text-warning-600 dark:text-warning-400">(includes orphaned groups)</span>
@endif
</p>
</div>
<!-- Scope Tags -->
@php
$scopeTags = $version->scope_tags['names'] ?? [];
@endphp
@if(!empty($scopeTags))
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Scope Tags</h4>
<div class="mt-2 flex flex-wrap gap-2">
@foreach($scopeTags as $tag)
<span class="inline-flex items-center rounded-md bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700 ring-1 ring-inset ring-primary-700/10 dark:bg-primary-400/10 dark:text-primary-400 dark:ring-primary-400/30">
{{ $tag }}
</span>
@endforeach
</div>
</div>
@endif
<!-- Assignment Details -->
<div>
<h4 class="text-sm font-medium text-gray-950 dark:text-white">Assignment Details</h4>
<div class="mt-2 space-y-2">
@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
<div class="flex items-center gap-2 text-sm">
<span class="text-gray-600 dark:text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
@if($groupId)
<span class="text-gray-600 dark:text-gray-400">:</span>
@if($hasOrphaned)
<span class="text-warning-600 dark:text-warning-400">
⚠️ Unknown Group (ID: {{ $groupId }})
</span>
@else
<span class="text-gray-700 dark:text-gray-300">
Group ID: {{ $groupId }}
</span>
@endif
@endif
<span class="ml-auto text-xs text-gray-500 dark:text-gray-500">({{ $intent }})</span>
</div>
@endforeach
</div>
</div>
</div>
</div>
@else
<div class="rounded-lg bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10">
<div class="px-6 py-4">
<h3 class="text-base font-semibold leading-6 text-gray-950 dark:text-white">
Assignments
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Assignments were not captured for this version.
</p>
@php
$hasBackupItem = $version->policy->backupItems()
->whereNotNull('assignments')
->where('created_at', '<=', $version->captured_at)
->exists();
@endphp
@if($hasBackupItem)
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
💡 Assignment data may be available in related backup items.
</p>
@endif
</div>
</div>
@endif
</div>

View File

@ -0,0 +1,262 @@
<?php
use App\Models\BackupItem;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\MicrosoftGraphClient;
use App\Services\Intune\BackupService;
use App\Services\Intune\GroupResolver;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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);
});

View File

@ -1,533 +0,0 @@
<?php
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\AssignmentBackupService;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\BackupService;
use App\Services\Intune\PolicySnapshotService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery\MockInterface;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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']);
});

View File

@ -0,0 +1,74 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
beforeEach(function () {
$this->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');
});

View File

@ -0,0 +1,173 @@
<?php
use App\Models\Policy;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GroupResolver;
use App\Services\Intune\PolicySnapshotService;
use App\Services\Intune\VersionService;
beforeEach(function () {
$this->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);
});

View File

@ -2,7 +2,7 @@
use App\Services\Graph\AssignmentFetcher; use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\GraphException; use App\Services\Graph\GraphException;
use App\Services\Graph\GraphLogger; use App\Services\Graph\GraphResponse;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -11,8 +11,7 @@
beforeEach(function () { beforeEach(function () {
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class); $this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->logger = Mockery::mock(GraphLogger::class); $this->fetcher = new AssignmentFetcher($this->graphClient);
$this->fetcher = new AssignmentFetcher($this->graphClient, $this->logger);
}); });
test('primary endpoint success', function () { test('primary endpoint success', function () {
@ -23,16 +22,18 @@
['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']], ['id' => 'assign-2', 'target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-2']],
]; ];
$this->graphClient $response = new GraphResponse(
->shouldReceive('get') success: true,
->once() data: ['value' => $assignments]
->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId) );
->andReturn(['value' => $assignments]);
$this->logger $this->graphClient
->shouldReceive('logDebug') ->shouldReceive('request')
->once() ->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); $result = $this->fetcher->fetch($tenantId, $policyId);
@ -47,25 +48,36 @@
]; ];
// Primary returns empty // Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient $this->graphClient
->shouldReceive('get') ->shouldReceive('request')
->once() ->once()
->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId) ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", [
->andReturn(['value' => []]); 'tenant' => $tenantId,
])
->andReturn($primaryResponse);
// Fallback returns assignments // Fallback returns assignments
$this->graphClient $fallbackResponse = new GraphResponse(
->shouldReceive('get') success: true,
->once() data: ['value' => [['id' => $policyId, 'assignments' => $assignments]]]
->with('/deviceManagement/configurationPolicies', $tenantId, [ );
'$expand' => 'assignments',
'$filter' => "id eq '{$policyId}'",
])
->andReturn(['value' => [['id' => $policyId, 'assignments' => $assignments]]]);
$this->logger $this->graphClient
->shouldReceive('logDebug') ->shouldReceive('request')
->twice(); ->once()
->with('GET', '/deviceManagement/configurationPolicies', [
'tenant' => $tenantId,
'query' => [
'$expand' => 'assignments',
'$filter' => "id eq '{$policyId}'",
],
])
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($tenantId, $policyId);
@ -77,19 +89,10 @@
$policyId = 'policy-456'; $policyId = 'policy-456';
$this->graphClient $this->graphClient
->shouldReceive('get') ->shouldReceive('request')
->once() ->once()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); ->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); $result = $this->fetcher->fetch($tenantId, $policyId);
expect($result)->toBe([]); expect($result)->toBe([]);
@ -100,22 +103,28 @@
$policyId = 'policy-456'; $policyId = 'policy-456';
// Primary returns empty // Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient $this->graphClient
->shouldReceive('get') ->shouldReceive('request')
->once() ->once()
->with("/deviceManagement/configurationPolicies/{$policyId}/assignments", $tenantId) ->with('GET', "/deviceManagement/configurationPolicies/{$policyId}/assignments", Mockery::any())
->andReturn(['value' => []]); ->andReturn($primaryResponse);
// Fallback returns empty // Fallback returns empty
$this->graphClient $fallbackResponse = new GraphResponse(
->shouldReceive('get') success: true,
->once() data: ['value' => []]
->with('/deviceManagement/configurationPolicies', $tenantId, Mockery::any()) );
->andReturn(['value' => []]);
$this->logger $this->graphClient
->shouldReceive('logDebug') ->shouldReceive('request')
->times(2); ->once()
->with('GET', '/deviceManagement/configurationPolicies', Mockery::any())
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($tenantId, $policyId);
@ -127,20 +136,26 @@
$policyId = 'policy-456'; $policyId = 'policy-456';
// Primary returns empty // Primary returns empty
$primaryResponse = new GraphResponse(
success: true,
data: ['value' => []]
);
$this->graphClient $this->graphClient
->shouldReceive('get') ->shouldReceive('request')
->once() ->once()
->andReturn(['value' => []]); ->andReturn($primaryResponse);
// Fallback returns policy without assignments key // Fallback returns policy without assignments key
$this->graphClient $fallbackResponse = new GraphResponse(
->shouldReceive('get') success: true,
->once() data: ['value' => [['id' => $policyId]]]
->andReturn(['value' => [['id' => $policyId]]]); );
$this->logger $this->graphClient
->shouldReceive('logDebug') ->shouldReceive('request')
->times(2); ->once()
->andReturn($fallbackResponse);
$result = $this->fetcher->fetch($tenantId, $policyId); $result = $this->fetcher->fetch($tenantId, $policyId);

View File

@ -1,7 +1,7 @@
<?php <?php
use App\Services\Graph\GraphException; use App\Services\Graph\GraphException;
use App\Services\Graph\GraphLogger; use App\Services\Graph\GraphResponse;
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use App\Services\Graph\MicrosoftGraphClient; use App\Services\Graph\MicrosoftGraphClient;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -13,14 +13,13 @@
beforeEach(function () { beforeEach(function () {
Cache::flush(); Cache::flush();
$this->graphClient = Mockery::mock(MicrosoftGraphClient::class); $this->graphClient = Mockery::mock(MicrosoftGraphClient::class);
$this->logger = Mockery::mock(GraphLogger::class); $this->resolver = new GroupResolver($this->graphClient);
$this->resolver = new GroupResolver($this->graphClient, $this->logger);
}); });
test('resolves all groups', function () { test('resolves all groups', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2', 'group-3']; $groupIds = ['group-1', 'group-2', 'group-3'];
$graphResponse = [ $graphData = [
'value' => [ 'value' => [
['id' => 'group-1', 'displayName' => 'All Users'], ['id' => 'group-1', 'displayName' => 'All Users'],
['id' => 'group-2', 'displayName' => 'HR Team'], ['id' => 'group-2', 'displayName' => 'HR Team'],
@ -28,18 +27,22 @@
], ],
]; ];
$this->graphClient $response = new GraphResponse(
->shouldReceive('post') success: true,
->once() data: $graphData
->with('/directoryObjects/getByIds', [ );
'ids' => $groupIds,
'types' => ['group'],
], $tenantId)
->andReturn($graphResponse);
$this->logger $this->graphClient
->shouldReceive('logDebug') ->shouldReceive('request')
->once(); ->once()
->with('POST', '/directoryObjects/getByIds', [
'tenant' => $tenantId,
'json' => [
'ids' => $groupIds,
'types' => ['group'],
],
])
->andReturn($response);
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId); $result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
@ -58,26 +61,22 @@
test('handles orphaned ids', function () { test('handles orphaned ids', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2', 'group-3']; $groupIds = ['group-1', 'group-2', 'group-3'];
$graphResponse = [ $graphData = [
'value' => [ 'value' => [
['id' => 'group-1', 'displayName' => 'All Users'], ['id' => 'group-1', 'displayName' => 'All Users'],
// group-2 and group-3 are missing (deleted) // group-2 and group-3 are missing (deleted)
], ],
]; ];
$this->graphClient $response = new GraphResponse(
->shouldReceive('post') success: true,
->once() data: $graphData
->andReturn($graphResponse); );
$this->logger $this->graphClient
->shouldReceive('logDebug') ->shouldReceive('request')
->once() ->once()
->with('Resolved group IDs', Mockery::on(function ($context) { ->andReturn($response);
return $context['requested'] === 3
&& $context['resolved'] === 1
&& $context['orphaned'] === 2;
}));
$result = $this->resolver->resolveGroupIds($groupIds, $tenantId); $result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
@ -96,22 +95,23 @@
test('caches results', function () { test('caches results', function () {
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$groupIds = ['group-1', 'group-2']; $groupIds = ['group-1', 'group-2'];
$graphResponse = [ $graphData = [
'value' => [ 'value' => [
['id' => 'group-1', 'displayName' => 'All Users'], ['id' => 'group-1', 'displayName' => 'All Users'],
['id' => 'group-2', 'displayName' => 'HR Team'], ['id' => 'group-2', 'displayName' => 'HR Team'],
], ],
]; ];
$response = new GraphResponse(
success: true,
data: $graphData
);
// First call - should hit Graph API // First call - should hit Graph API
$this->graphClient $this->graphClient
->shouldReceive('post') ->shouldReceive('request')
->once() ->once()
->andReturn($graphResponse); ->andReturn($response);
$this->logger
->shouldReceive('logDebug')
->once();
$result1 = $this->resolver->resolveGroupIds($groupIds, $tenantId); $result1 = $this->resolver->resolveGroupIds($groupIds, $tenantId);
@ -133,18 +133,10 @@
$groupIds = ['group-1', 'group-2']; $groupIds = ['group-1', 'group-2'];
$this->graphClient $this->graphClient
->shouldReceive('post') ->shouldReceive('request')
->once() ->once()
->andThrow(new GraphException('Graph API error', 500, ['request_id' => 'request-id-123'])); ->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); $result = $this->resolver->resolveGroupIds($groupIds, $tenantId);
// All groups should be marked as orphaned on failure // All groups should be marked as orphaned on failure
@ -159,7 +151,7 @@
$tenantId = 'tenant-123'; $tenantId = 'tenant-123';
$groupIds1 = ['group-1', 'group-2', 'group-3']; $groupIds1 = ['group-1', 'group-2', 'group-3'];
$groupIds2 = ['group-3', 'group-1', 'group-2']; // Different order $groupIds2 = ['group-3', 'group-1', 'group-2']; // Different order
$graphResponse = [ $graphData = [
'value' => [ 'value' => [
['id' => 'group-1', 'displayName' => 'All Users'], ['id' => 'group-1', 'displayName' => 'All Users'],
['id' => 'group-2', 'displayName' => 'HR Team'], ['id' => 'group-2', 'displayName' => 'HR Team'],
@ -167,15 +159,16 @@
], ],
]; ];
$response = new GraphResponse(
success: true,
data: $graphData
);
// First call with groupIds1 // First call with groupIds1
$this->graphClient $this->graphClient
->shouldReceive('post') ->shouldReceive('request')
->once() ->once()
->andReturn($graphResponse); ->andReturn($response);
$this->logger
->shouldReceive('logDebug')
->once();
$result1 = $this->resolver->resolveGroupIds($groupIds1, $tenantId); $result1 = $this->resolver->resolveGroupIds($groupIds1, $tenantId);