diff --git a/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php new file mode 100644 index 0000000..8c684cc --- /dev/null +++ b/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php @@ -0,0 +1,116 @@ +isDueAt($now, $timeUtc)) { + return self::SUCCESS; + } + + if (! class_exists(\App\Jobs\EntraGroupSyncJob::class)) { + $this->warn('EntraGroupSyncJob is not available; skipping scheduled directory group sync dispatch.'); + + return self::SUCCESS; + } + + $tenantIdentifiers = array_values(array_filter(array_map('strval', array_merge( + (array) $this->option('tenant'), + (array) config('directory_groups.schedule.tenants', []), + )))); + + $tenants = $this->resolveTenants($tenantIdentifiers); + + $selectionKey = 'groups-v1:all'; + $slotKey = $now->format('YmdHi').'Z'; + + $created = 0; + $skipped = 0; + + foreach ($tenants as $tenant) { + $inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => $slotKey, + 'status' => 'pending', + 'initiator_user_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($inserted === 1) { + $created++; + + dispatch(new \App\Jobs\EntraGroupSyncJob( + tenantId: $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: $slotKey, + )); + } else { + $skipped++; + } + } + + $this->info(sprintf( + 'Scanned %d tenant(s), created %d run(s), skipped %d duplicate run(s).', + $tenants->count(), + $created, + $skipped, + )); + + return self::SUCCESS; + } + + /** + * @param array $tenantIdentifiers + */ + private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection + { + $query = Tenant::activeQuery(); + + if ($tenantIdentifiers !== []) { + $query->where(function ($subQuery) use ($tenantIdentifiers) { + foreach ($tenantIdentifiers as $identifier) { + if (ctype_digit($identifier)) { + $subQuery->orWhereKey((int) $identifier); + + continue; + } + + $subQuery->orWhere('tenant_id', $identifier) + ->orWhere('external_id', $identifier); + } + }); + } + + return $query->get(); + } + + private function isDueAt(CarbonImmutable $now, string $timeUtc): bool + { + if (! preg_match('/^(?[01]\\d|2[0-3]):(?[0-5]\\d)$/', $timeUtc, $matches)) { + return false; + } + + return (int) $matches['hour'] === (int) $now->format('H') + && (int) $matches['minute'] === (int) $now->format('i'); + } +} diff --git a/app/Filament/Resources/EntraGroupResource.php b/app/Filament/Resources/EntraGroupResource.php new file mode 100644 index 0000000..2479125 --- /dev/null +++ b/app/Filament/Resources/EntraGroupResource.php @@ -0,0 +1,211 @@ +schema([ + Section::make('Group') + ->schema([ + TextEntry::make('display_name')->label('Name'), + TextEntry::make('entra_id')->label('Entra ID')->copyable(), + TextEntry::make('type') + ->badge() + ->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))), + TextEntry::make('security_enabled')->label('Security')->badge(), + TextEntry::make('mail_enabled')->label('Mail')->badge(), + TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Raw groupTypes') + ->schema([ + ViewEntry::make('group_types') + ->label('') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (EntraGroup $record) => $record->group_types ?? []) + ->columnSpanFull(), + ]) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('display_name') + ->modifyQueryUsing(function (Builder $query): Builder { + $tenantId = Tenant::current()?->getKey(); + + return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + }) + ->columns([ + Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(), + Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(), + Tables\Columns\TextColumn::make('type') + ->label('Type') + ->badge() + ->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))) + ->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))), + Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'), + ]) + ->filters([ + SelectFilter::make('stale') + ->label('Stale') + ->options([ + '1' => 'Stale', + '0' => 'Fresh', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + + if ((string) $value === '1') { + return $query->where(function (Builder $q) use ($cutoff): void { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $cutoff); + }); + } + + return $query->where('last_seen_at', '>=', $cutoff); + }), + + SelectFilter::make('group_type') + ->label('Type') + ->options([ + 'security' => 'Security', + 'microsoft365' => 'Microsoft 365', + 'mail' => 'Mail-enabled', + 'unknown' => 'Unknown', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? ''); + + if ($value === '') { + return $query; + } + + return match ($value) { + 'microsoft365' => $query->whereJsonContains('group_types', 'Unified'), + 'security' => $query + ->where('security_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'mail' => $query + ->where('mail_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'unknown' => $query + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }) + ->where('security_enabled', false) + ->where('mail_enabled', false), + default => $query, + }; + }), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery()->latest('id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEntraGroups::route('/'), + 'view' => Pages\ViewEntraGroup::route('/{record}'), + ]; + } + + private static function groupType(EntraGroup $record): string + { + $groupTypes = $record->group_types; + + if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) { + return 'microsoft365'; + } + + if ($record->security_enabled) { + return 'security'; + } + + if ($record->mail_enabled) { + return 'mail'; + } + + return 'unknown'; + } + + private static function groupTypeLabel(string $type): string + { + return match ($type) { + 'microsoft365' => 'Microsoft 365', + 'security' => 'Security', + 'mail' => 'Mail-enabled', + default => 'Unknown', + }; + } + + private static function groupTypeColor(string $type): string + { + return match ($type) { + 'microsoft365' => 'info', + 'security' => 'success', + 'mail' => 'warning', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php new file mode 100644 index 0000000..6079093 --- /dev/null +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -0,0 +1,119 @@ +label('Group Sync Runs') + ->icon('heroicon-o-clock') + ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => (bool) Tenant::current()), + + Action::make('sync_groups') + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + $role = $user->tenantRole($tenant); + + return $role?->canSync() ?? false; + }) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::current(); + + if (! $tenant) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(403); + } + + $role = $user->tenantRole($tenant); + + if (! ($role?->canSync() ?? false)) { + abort(403); + } + + $selectionKey = EntraGroupSelection::allGroupsV1(); + + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $existing->getKey(), + 'status' => $normalizedStatus, + ])); + + return; + } + + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', + ])); + }), + ]; + } +} diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php b/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php new file mode 100644 index 0000000..c7098c9 --- /dev/null +++ b/app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php @@ -0,0 +1,11 @@ +schema([ + Section::make('Sync Run') + ->schema([ + TextEntry::make('initiator.name') + ->label('Initiator') + ->placeholder('—'), + TextEntry::make('status') + ->badge() + ->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)), + TextEntry::make('selection_key')->label('Selection'), + TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(), + TextEntry::make('started_at')->dateTime(), + TextEntry::make('finished_at')->dateTime(), + TextEntry::make('pages_fetched')->label('Pages')->numeric(), + TextEntry::make('items_observed_count')->label('Observed')->numeric(), + TextEntry::make('items_upserted_count')->label('Upserted')->numeric(), + TextEntry::make('error_count')->label('Errors')->numeric(), + TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(), + TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'), + ]) + ->columns(2) + ->columnSpanFull(), + + Section::make('Error Summary') + ->schema([ + TextEntry::make('error_code')->placeholder('—'), + TextEntry::make('error_category')->placeholder('—'), + ViewEntry::make('error_summary') + ->label('Safe error summary') + ->view('filament.infolists.entries.snapshot-json') + ->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : []) + ->columnSpanFull(), + ]) + ->columns(2) + ->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->defaultSort('id', 'desc') + ->modifyQueryUsing(function (Builder $query): Builder { + $tenantId = Tenant::current()?->getKey(); + + return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId)); + }) + ->columns([ + Tables\Columns\TextColumn::make('initiator.name') + ->label('Initiator') + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('status') + ->badge() + ->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)), + Tables\Columns\TextColumn::make('selection_key') + ->label('Selection') + ->limit(24) + ->copyable(), + Tables\Columns\TextColumn::make('slot_key') + ->label('Slot') + ->placeholder('—') + ->limit(16) + ->copyable(), + Tables\Columns\TextColumn::make('started_at')->since(), + Tables\Columns\TextColumn::make('finished_at')->since(), + Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(), + Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(), + Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(), + Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(), + ]) + ->actions([ + Actions\ViewAction::make(), + ]) + ->bulkActions([]); + } + + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->with('initiator') + ->latest('id'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListEntraGroupSyncRuns::route('/'), + 'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'), + ]; + } + + private static function statusColor(?string $status): string + { + return match ($status) { + EntraGroupSyncRun::STATUS_SUCCEEDED => 'success', + EntraGroupSyncRun::STATUS_PARTIAL => 'warning', + EntraGroupSyncRun::STATUS_FAILED => 'danger', + EntraGroupSyncRun::STATUS_RUNNING => 'info', + EntraGroupSyncRun::STATUS_PENDING => 'gray', + default => 'gray', + }; + } +} diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php new file mode 100644 index 0000000..855fbab --- /dev/null +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -0,0 +1,112 @@ +label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + $role = $user->tenantRole($tenant); + + return $role?->canSync() ?? false; + }) + ->action(function (): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + $tenant = Tenant::current(); + + if (! $tenant) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(403); + } + + $role = $user->tenantRole($tenant); + + if (! ($role?->canSync() ?? false)) { + abort(403); + } + + $selectionKey = EntraGroupSelection::allGroupsV1(); + + $existing = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + $normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued'; + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $existing->getKey(), + 'status' => $normalizedStatus, + ])); + + return; + } + + $run = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); + + $user->notify(new RunStatusChangedNotification([ + 'tenant_id' => (int) $tenant->getKey(), + 'run_type' => 'directory_groups', + 'run_id' => (int) $run->getKey(), + 'status' => 'queued', + ])); + }), + ]; + } +} diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php new file mode 100644 index 0000000..d48840f --- /dev/null +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ViewEntraGroupSyncRun.php @@ -0,0 +1,11 @@ +where('tenant_id', $tenant->getKey()); + $hasCachedGroups = $groupCacheQuery->exists(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + + $cacheNotice = match (true) { + ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', + $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", + default => null, + }; + + return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; @@ -120,7 +136,28 @@ public static function form(Schema $schema): Schema ->placeholder('SKIP or target group Object ID (GUID)') ->rules([new SkipOrUuidRule]) ->required() - ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Select from Directory cache') + ->modalHeading('Select from Directory cache') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + ])) + ) + ->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') + ->hintAction( + Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => $cacheNotice !== null) + ); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -317,7 +354,21 @@ public static function getWizardSteps(): array tenant: $tenant ); - return array_map(function (array $group): Forms\Components\TextInput { + $groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey()); + $hasCachedGroups = $groupCacheQuery->exists(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + $latestSeen = $groupCacheQuery->max('last_seen_at'); + $isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff); + + $cacheNotice = match (true) { + ! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.', + $isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".", + default => null, + }; + + return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput { $groupId = $group['id']; $label = $group['label']; @@ -335,7 +386,28 @@ public static function getWizardSteps(): array $set('preview_ran_at', null); }) ->required() - ->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.'); + ->suffixAction( + Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId)) + ->icon('heroicon-o-magnifying-glass') + ->iconButton() + ->tooltip('Select from Directory cache') + ->modalHeading('Select from Directory cache') + ->modalWidth('5xl') + ->modalSubmitAction(false) + ->modalCancelActionLabel('Close') + ->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [ + 'sourceGroupId' => $groupId, + ])) + ) + ->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.') + ->hintAction( + Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId)) + ->label('Sync Groups') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())) + ->visible(fn (): bool => $cacheNotice !== null) + ); }, $unresolved); }) ->visible(function (Get $get): bool { @@ -1541,10 +1613,16 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return []; } - return array_map(function (string $groupId) use ($sourceNames): array { + $resolver = app(EntraGroupLabelResolver::class); + $cached = $resolver->lookupMany($tenant, $groupIds); + + return array_map(function (string $groupId) use ($sourceNames, $cached): array { + $cachedName = $cached[strtolower($groupId)] ?? null; + $fallbackName = $cachedName ?? ($sourceNames[$groupId] ?? null); + return [ 'id' => $groupId, - 'label' => static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId), + 'label' => EntraGroupLabelResolver::formatLabel($fallbackName, $groupId), ]; }, $groupIds); } @@ -1653,11 +1731,4 @@ private static function normalizeGroupMapping(mixed $mapping): array return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== ''); } - - private static function formatGroupLabel(?string $displayName, string $id): string - { - $suffix = '…'.mb_substr($id, -8); - - return trim(($displayName ?: 'Security group').' ('.$suffix.')'); - } } diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index 2e4e6de..b799644 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -9,6 +9,7 @@ use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; +use Livewire\Attributes\On; class CreateRestoreRun extends CreateRecord { @@ -119,4 +120,23 @@ protected function handleRecordCreation(array $data): Model { return RestoreRunResource::createRestoreRun($data); } + + #[On('entra-group-cache-picked')] + public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void + { + data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId); + + $this->data['check_summary'] = null; + $this->data['check_results'] = []; + $this->data['checks_ran_at'] = null; + $this->data['preview_summary'] = null; + $this->data['preview_diffs'] = []; + $this->data['preview_ran_at'] = null; + + $this->form->fill($this->data); + + if (method_exists($this, 'unmountAction')) { + $this->unmountAction(); + } + } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index f32bd90..f5efe27 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -6,9 +6,11 @@ use App\Http\Controllers\RbacDelegatedAuthController; use App\Jobs\BulkTenantSyncJob; use App\Jobs\SyncPoliciesJob; +use App\Models\EntraGroup; use App\Models\Tenant; use App\Models\User; use App\Services\BulkOperationService; +use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; use App\Services\Intune\RbacHealthService; @@ -585,10 +587,7 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('scope') === 'scope_group') ->required(fn (Get $get) => $get('scope') === 'scope_group') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) - ->hint(fn (?Tenant $record) => static::groupSearchHelper($record)) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) ->noSearchResultsMessage('No security groups found') @@ -615,10 +614,7 @@ public static function rbacAction(): Actions\Action ->placeholder('Search security groups') ->visible(fn (Get $get) => $get('group_mode') === 'existing') ->required(fn (Get $get) => $get('group_mode') === 'existing') - ->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null) ->helperText(fn (?Tenant $record) => static::groupSearchHelper($record)) - ->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record)) - ->hint(fn (?Tenant $record) => static::groupSearchHelper($record)) ->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search)) ->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value)) ->noSearchResultsMessage('No security groups found') @@ -928,6 +924,11 @@ private static function formatRoleLabel(?string $displayName, string $id): strin return trim(($displayName ?: 'RBAC role').$suffix); } + private static function escapeOdataValue(string $value): string + { + return str_replace("'", "''", $value); + } + private static function notifyRoleLookupFailure(): void { Notification::make() @@ -980,65 +981,30 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act public static function groupSearchHelper(?Tenant $tenant): ?string { - return static::delegatedToken($tenant) ? null : 'Login to search groups'; + if (! $tenant) { + return null; + } + + return 'Uses cached directory groups only (no live Graph lookups). Run “Sync Groups” if results are empty.'; } /** * @return array */ public static function groupSearchOptions(?Tenant $tenant, string $search): array - { - return static::searchSecurityGroups($tenant, $search); - } - - /** - * @return array - */ - private static function searchSecurityGroups(?Tenant $tenant, string $search): array { if (! $tenant || mb_strlen($search) < 2) { return []; } - $token = static::delegatedToken($tenant); - - if (! $token) { - return []; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - 'groups', - [ - 'query' => [ - '$filter' => sprintf( - "securityEnabled eq true and startswith(displayName,'%s')", - static::escapeOdataValue($search) - ), - '$select' => 'id,displayName', - '$top' => 20, - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyGroupLookupFailure(); - - return []; - } - - if ($response->failed()) { - static::notifyGroupLookupFailure(); - - return []; - } - - return collect($response->data['value'] ?? []) - ->filter(fn (array $group) => filled($group['id'] ?? null)) - ->mapWithKeys(fn (array $group) => [ - $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']), + return EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->where('display_name', 'ilike', '%'.str_replace('%', '\\%', $search).'%') + ->orderBy('display_name') + ->limit(20) + ->get(['entra_id', 'display_name']) + ->mapWithKeys(fn (EntraGroup $group) => [ + (string) $group->entra_id => EntraGroupLabelResolver::formatLabel($group->display_name, (string) $group->entra_id), ]) ->all(); } @@ -1049,61 +1015,7 @@ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?s return $groupId; } - $token = static::delegatedToken($tenant); - - if (! $token) { - return $groupId; - } - - try { - $response = app(GraphClientInterface::class)->request( - 'GET', - "groups/{$groupId}", - [ - 'query' => [ - '$select' => 'id,displayName', - ], - ] + $tenant->graphOptions() + [ - 'access_token' => $token, - ] - ); - } catch (Throwable) { - static::notifyGroupLookupFailure(); - - return $groupId; - } - - if ($response->failed()) { - static::notifyGroupLookupFailure(); - - return $groupId; - } - - $displayName = $response->data['displayName'] ?? null; - $id = $response->data['id'] ?? $groupId; - - return static::formatGroupLabel($displayName, $id); - } - - private static function formatGroupLabel(?string $displayName, string $id): string - { - $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); - - return trim(($displayName ?: 'Security group').$suffix); - } - - private static function escapeOdataValue(string $value): string - { - return str_replace("'", "''", $value); - } - - private static function notifyGroupLookupFailure(): void - { - Notification::make() - ->title('Group lookup failed') - ->body('Delegated session may have expired. Login again to search security groups.') - ->danger() - ->send(); + return app(EntraGroupLabelResolver::class)->resolveOne($tenant, $groupId); } public static function verifyTenant( diff --git a/app/Jobs/EntraGroupSyncJob.php b/app/Jobs/EntraGroupSyncJob.php new file mode 100644 index 0000000..cb52e8a --- /dev/null +++ b/app/Jobs/EntraGroupSyncJob.php @@ -0,0 +1,139 @@ +find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $run = $this->resolveRun($tenant); + + if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) { + return; + } + + $run->update([ + 'status' => EntraGroupSyncRun::STATUS_RUNNING, + 'started_at' => CarbonImmutable::now('UTC'), + ]); + + $auditLogger->log( + tenant: $tenant, + action: 'directory_groups.sync.started', + context: [ + 'selection_key' => $run->selection_key, + 'run_id' => $run->getKey(), + 'slot_key' => $run->slot_key, + ], + actorId: $run->initiator_user_id, + status: 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $run->getKey(), + ); + + $result = $syncService->sync($tenant, $run); + + $terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED; + + if ($result['error_code'] !== null) { + $terminalStatus = EntraGroupSyncRun::STATUS_FAILED; + } elseif ($result['safety_stop_triggered'] === true) { + $terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL; + } + + $run->update([ + 'status' => $terminalStatus, + 'pages_fetched' => $result['pages_fetched'], + 'items_observed_count' => $result['items_observed_count'], + 'items_upserted_count' => $result['items_upserted_count'], + 'error_count' => $result['error_count'], + 'safety_stop_triggered' => $result['safety_stop_triggered'], + 'safety_stop_reason' => $result['safety_stop_reason'], + 'error_code' => $result['error_code'], + 'error_category' => $result['error_category'], + 'error_summary' => $result['error_summary'], + 'finished_at' => CarbonImmutable::now('UTC'), + ]); + + $auditLogger->log( + tenant: $tenant, + action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED + ? 'directory_groups.sync.succeeded' + : ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL + ? 'directory_groups.sync.partial' + : 'directory_groups.sync.failed'), + context: [ + 'selection_key' => $run->selection_key, + 'run_id' => $run->getKey(), + 'slot_key' => $run->slot_key, + 'pages_fetched' => $run->pages_fetched, + 'items_observed_count' => $run->items_observed_count, + 'items_upserted_count' => $run->items_upserted_count, + 'error_code' => $run->error_code, + 'error_category' => $run->error_category, + ], + actorId: $run->initiator_user_id, + status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success', + resourceType: 'entra_group_sync_run', + resourceId: (string) $run->getKey(), + ); + } + + private function resolveRun(Tenant $tenant): EntraGroupSyncRun + { + if ($this->runId !== null) { + $run = EntraGroupSyncRun::query() + ->whereKey($this->runId) + ->where('tenant_id', $tenant->getKey()) + ->first(); + + if ($run instanceof EntraGroupSyncRun) { + return $run; + } + + throw new RuntimeException('EntraGroupSyncRun not found.'); + } + + if ($this->slotKey !== null) { + $run = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', $this->selectionKey) + ->where('slot_key', $this->slotKey) + ->first(); + + if ($run instanceof EntraGroupSyncRun) { + return $run; + } + + throw new RuntimeException('EntraGroupSyncRun not found for slot.'); + } + + throw new RuntimeException('Job missing runId/slotKey.'); + } +} diff --git a/app/Livewire/EntraGroupCachePickerTable.php b/app/Livewire/EntraGroupCachePickerTable.php new file mode 100644 index 0000000..f79834d --- /dev/null +++ b/app/Livewire/EntraGroupCachePickerTable.php @@ -0,0 +1,203 @@ +sourceGroupId = $sourceGroupId; + } + + public function table(Table $table): Table + { + $tenantId = Tenant::current()?->getKey(); + + $query = EntraGroup::query(); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereRaw('1 = 0'); + } + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + $cutoff = now('UTC')->subDays(max(1, $stalenessDays)); + + return $table + ->queryStringIdentifier('entraGroupCachePicker') + ->query($query) + ->defaultSort('display_name') + ->paginated([10, 25, 50]) + ->defaultPaginationPageOption(10) + ->searchable() + ->searchPlaceholder('Search groups…') + ->deferLoading(! app()->runningUnitTests()) + ->columns([ + TextColumn::make('display_name') + ->label('Name') + ->searchable() + ->sortable() + ->wrap() + ->limit(60), + TextColumn::make('type') + ->label('Type') + ->badge() + ->state(fn (EntraGroup $record): string => $this->groupTypeLabel($this->groupType($record))) + ->color(fn (EntraGroup $record): string => $this->groupTypeColor($this->groupType($record))) + ->toggleable(), + TextColumn::make('entra_id') + ->label('ID') + ->formatStateUsing(fn (?string $state): string => filled($state) ? ('…'.substr($state, -8)) : '—') + ->extraAttributes(['class' => 'font-mono']) + ->toggleable(), + TextColumn::make('last_seen_at') + ->label('Last seen') + ->since() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('stale') + ->label('Stale') + ->options([ + '1' => 'Stale', + '0' => 'Fresh', + ]) + ->query(function (Builder $query, array $data) use ($cutoff): Builder { + $value = $data['value'] ?? null; + + if ($value === null || $value === '') { + return $query; + } + + if ((string) $value === '1') { + return $query->where(function (Builder $q) use ($cutoff): void { + $q->whereNull('last_seen_at') + ->orWhere('last_seen_at', '<', $cutoff); + }); + } + + return $query->where('last_seen_at', '>=', $cutoff); + }), + + SelectFilter::make('group_type') + ->label('Type') + ->options([ + 'security' => 'Security', + 'microsoft365' => 'Microsoft 365', + 'mail' => 'Mail-enabled', + 'unknown' => 'Unknown', + ]) + ->query(function (Builder $query, array $data): Builder { + $value = (string) ($data['value'] ?? ''); + + if ($value === '') { + return $query; + } + + return match ($value) { + 'microsoft365' => $query->whereJsonContains('group_types', 'Unified'), + 'security' => $query + ->where('security_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'mail' => $query + ->where('mail_enabled', true) + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }), + 'unknown' => $query + ->where(function (Builder $q): void { + $q->whereNull('group_types') + ->orWhereJsonDoesntContain('group_types', 'Unified'); + }) + ->where('security_enabled', false) + ->where('mail_enabled', false), + default => $query, + }; + }), + ]) + ->actions([ + Action::make('select') + ->label('Select') + ->icon('heroicon-o-check') + ->color('primary') + ->action(function (EntraGroup $record): void { + $this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id); + }), + ]) + ->emptyStateHeading('No cached groups found') + ->emptyStateDescription('Run “Sync Groups” first, then come back here.') + ->emptyStateActions([ + Action::make('open_groups') + ->label('Directory Groups') + ->icon('heroicon-o-user-group') + ->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())), + Action::make('open_sync_runs') + ->label('Group Sync Runs') + ->icon('heroicon-o-clock') + ->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())), + ]); + } + + public function render(): View + { + return view('livewire.entra-group-cache-picker-table'); + } + + private function groupType(EntraGroup $record): string + { + $groupTypes = $record->group_types; + + if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) { + return 'microsoft365'; + } + + if ($record->security_enabled) { + return 'security'; + } + + if ($record->mail_enabled) { + return 'mail'; + } + + return 'unknown'; + } + + private function groupTypeLabel(string $type): string + { + return match ($type) { + 'microsoft365' => 'Microsoft 365', + 'security' => 'Security', + 'mail' => 'Mail-enabled', + default => 'Unknown', + }; + } + + private function groupTypeColor(string $type): string + { + return match ($type) { + 'microsoft365' => 'info', + 'security' => 'success', + 'mail' => 'warning', + default => 'gray', + }; + } +} diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php index 4067708..12d66de 100644 --- a/app/Livewire/PolicyVersionAssignmentsWidget.php +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -3,6 +3,8 @@ namespace App\Livewire; use App\Models\PolicyVersion; +use App\Models\Tenant; +use App\Services\Directory\EntraGroupLabelResolver; use Livewire\Component; class PolicyVersionAssignmentsWidget extends Component @@ -19,9 +21,75 @@ public function render(): \Illuminate\Contracts\View\View return view('livewire.policy-version-assignments-widget', [ 'version' => $this->version, 'compliance' => $this->complianceNotifications(), + 'groupLabels' => $this->groupLabels(), ]); } + /** + * @return array + */ + private function groupLabels(): array + { + $assignments = $this->version->assignments; + + if (! is_array($assignments) || $assignments === []) { + return []; + } + + $tenant = rescue(fn () => Tenant::current(), null); + + if (! $tenant instanceof Tenant) { + return []; + } + + $groupIds = []; + $sourceNames = []; + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $target = $assignment['target'] ?? null; + + if (! is_array($target)) { + continue; + } + + $groupId = $target['groupId'] ?? null; + + if (! is_string($groupId) || $groupId === '') { + continue; + } + + $groupIds[] = $groupId; + + $displayName = $target['group_display_name'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + $sourceNames[$groupId] = $displayName; + } + } + + $groupIds = array_values(array_unique($groupIds)); + + if ($groupIds === []) { + return []; + } + + $resolver = app(EntraGroupLabelResolver::class); + $cached = $resolver->lookupMany($tenant, $groupIds); + + $labels = []; + + foreach ($groupIds as $groupId) { + $cachedName = $cached[strtolower($groupId)] ?? null; + $labels[$groupId] = EntraGroupLabelResolver::formatLabel($cachedName ?? ($sourceNames[$groupId] ?? null), $groupId); + } + + return $labels; + } + /** * @return array{total:int,templates:array,items:array} */ diff --git a/app/Models/EntraGroup.php b/app/Models/EntraGroup.php new file mode 100644 index 0000000..e8f4b27 --- /dev/null +++ b/app/Models/EntraGroup.php @@ -0,0 +1,26 @@ + 'array', + 'security_enabled' => 'boolean', + 'mail_enabled' => 'boolean', + 'last_seen_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/EntraGroupSyncRun.php b/app/Models/EntraGroupSyncRun.php new file mode 100644 index 0000000..c02f752 --- /dev/null +++ b/app/Models/EntraGroupSyncRun.php @@ -0,0 +1,40 @@ + 'boolean', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function initiator(): BelongsTo + { + return $this->belongsTo(User::class, 'initiator_user_id'); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 1294555..a973d2e 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -195,6 +195,16 @@ public function restoreRuns(): HasMany return $this->hasMany(RestoreRun::class); } + public function entraGroups(): HasMany + { + return $this->hasMany(EntraGroup::class); + } + + public function entraGroupSyncRuns(): HasMany + { + return $this->hasMany(EntraGroupSyncRun::class); + } + public function auditLogs(): HasMany { return $this->hasMany(AuditLog::class); diff --git a/app/Notifications/RunStatusChangedNotification.php b/app/Notifications/RunStatusChangedNotification.php index 2faab26..0a0f6d0 100644 --- a/app/Notifications/RunStatusChangedNotification.php +++ b/app/Notifications/RunStatusChangedNotification.php @@ -3,6 +3,7 @@ namespace App\Notifications; use App\Filament\Resources\BulkOperationRunResource; +use App\Filament\Resources\EntraGroupSyncRunResource; use App\Filament\Resources\RestoreRunResource; use App\Models\Tenant; use Filament\Actions\Action; @@ -60,13 +61,34 @@ public function toDatabase(object $notifiable): array $actions = []; - if (in_array($runType, ['bulk_operation', 'restore'], true) && $tenantId > 0 && $runId > 0) { + if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) { $tenant = Tenant::query()->find($tenantId); if ($tenant) { - $url = $runType === 'bulk_operation' - ? BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant) - : RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant); + $url = match ($runType) { + 'bulk_operation' => BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + 'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant), + default => null, + }; + + if (! $url) { + return [ + 'format' => 'filament', + 'title' => $title, + 'body' => $body, + 'color' => $color, + 'duration' => 'persistent', + 'actions' => [], + 'icon' => null, + 'iconColor' => null, + 'status' => null, + 'view' => null, + 'viewData' => [ + 'metadata' => $this->metadata, + ], + ]; + } $actions[] = Action::make('view_run') ->label('View run') diff --git a/app/Policies/EntraGroupPolicy.php b/app/Policies/EntraGroupPolicy.php new file mode 100644 index 0000000..24b88dd --- /dev/null +++ b/app/Policies/EntraGroupPolicy.php @@ -0,0 +1,39 @@ +canAccessTenant($tenant); + } + + public function view(User $user, EntraGroup $group): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $group->tenant_id === (int) $tenant->getKey(); + } +} diff --git a/app/Policies/EntraGroupSyncRunPolicy.php b/app/Policies/EntraGroupSyncRunPolicy.php new file mode 100644 index 0000000..09865da --- /dev/null +++ b/app/Policies/EntraGroupSyncRunPolicy.php @@ -0,0 +1,39 @@ +canAccessTenant($tenant); + } + + public function view(User $user, EntraGroupSyncRun $run): bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if (! $user->canAccessTenant($tenant)) { + return false; + } + + return (int) $run->tenant_id === (int) $tenant->getKey(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a605e4a..1ffcf4a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -108,5 +108,7 @@ public function boot(): void Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class); Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class); + Gate::policy(\App\Models\EntraGroupSyncRun::class, \App\Policies\EntraGroupSyncRunPolicy::class); + Gate::policy(\App\Models\EntraGroup::class, \App\Policies\EntraGroupPolicy::class); } } diff --git a/app/Services/BulkOperationService.php b/app/Services/BulkOperationService.php index b85193a..6530c55 100644 --- a/app/Services/BulkOperationService.php +++ b/app/Services/BulkOperationService.php @@ -45,6 +45,8 @@ public function createRun( array $itemIds, int $totalItems ): BulkOperationRun { + $effectiveTotalItems = max($totalItems, count($itemIds)); + $run = BulkOperationRun::create([ 'tenant_id' => $tenant->id, 'user_id' => $user->id, @@ -52,7 +54,7 @@ public function createRun( 'action' => $action, 'status' => 'pending', 'item_ids' => $itemIds, - 'total_items' => $totalItems, + 'total_items' => $effectiveTotalItems, 'processed_items' => 0, 'succeeded' => 0, 'failed' => 0, @@ -66,7 +68,7 @@ public function createRun( context: [ 'metadata' => [ 'bulk_run_id' => $run->id, - 'total_items' => $totalItems, + 'total_items' => $effectiveTotalItems, ], ], actorId: $user->id, @@ -139,6 +141,14 @@ public function complete(BulkOperationRun $run): void { $run->refresh(); + if ($run->processed_items > $run->total_items) { + BulkOperationRun::query() + ->whereKey($run->id) + ->update(['total_items' => $run->processed_items]); + + $run->refresh(); + } + if (! in_array($run->status, ['pending', 'running'], true)) { return; } diff --git a/app/Services/Directory/EntraGroupLabelResolver.php b/app/Services/Directory/EntraGroupLabelResolver.php new file mode 100644 index 0000000..0691983 --- /dev/null +++ b/app/Services/Directory/EntraGroupLabelResolver.php @@ -0,0 +1,97 @@ +resolveMany($tenant, [$groupId]); + + return $labels[$groupId] ?? self::formatLabel(null, $groupId); + } + + /** + * @param array $groupIds + * @return array + */ + public function resolveMany(Tenant $tenant, array $groupIds): array + { + $groupIds = array_values(array_unique(array_filter($groupIds, fn ($id) => is_string($id) && $id !== ''))); + + if ($groupIds === []) { + return []; + } + + $displayNames = $this->lookupMany($tenant, $groupIds); + + $labels = []; + + foreach ($groupIds as $groupId) { + $lookupId = Str::isUuid($groupId) ? strtolower($groupId) : $groupId; + $labels[$groupId] = self::formatLabel($displayNames[$lookupId] ?? null, $groupId); + } + + return $labels; + } + + /** + * @param array $groupIds + * @return array Map of groupId (lowercased UUID) => display_name + */ + public function lookupMany(Tenant $tenant, array $groupIds): array + { + $uuids = []; + + foreach ($groupIds as $groupId) { + if (! is_string($groupId) || $groupId === '') { + continue; + } + + if (! Str::isUuid($groupId)) { + continue; + } + + $uuids[] = strtolower($groupId); + } + + $uuids = array_values(array_unique($uuids)); + + if ($uuids === []) { + return []; + } + + return EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('entra_id', $uuids) + ->pluck('display_name', 'entra_id') + ->mapWithKeys(fn (string $displayName, string $entraId) => [strtolower($entraId) => $displayName]) + ->all(); + } + + public static function formatLabel(?string $displayName, string $id): string + { + $name = filled($displayName) ? $displayName : 'Unresolved'; + + return sprintf('%s (%s)', trim($name), self::shortToken($id)); + } + + private static function shortToken(string $id): string + { + $normalized = preg_replace('/[^a-zA-Z0-9]/', '', $id); + + if (! is_string($normalized) || $normalized === '') { + return 'unknown'; + } + + if (mb_strlen($normalized) <= 8) { + return $normalized; + } + + return '…'.mb_substr($normalized, -8); + } +} diff --git a/app/Services/Directory/EntraGroupSelection.php b/app/Services/Directory/EntraGroupSelection.php new file mode 100644 index 0000000..a9e0e64 --- /dev/null +++ b/app/Services/Directory/EntraGroupSelection.php @@ -0,0 +1,20 @@ +format('YmdHi').'Z'; + } +} diff --git a/app/Services/Directory/EntraGroupSyncService.php b/app/Services/Directory/EntraGroupSyncService.php new file mode 100644 index 0000000..e3a437e --- /dev/null +++ b/app/Services/Directory/EntraGroupSyncService.php @@ -0,0 +1,262 @@ +where('tenant_id', $tenant->getKey()) + ->where('selection_key', $selectionKey) + ->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING]) + ->orderByDesc('id') + ->first(); + + if ($existing instanceof EntraGroupSyncRun) { + return $existing; + } + + $run = EntraGroupSyncRun::create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => $selectionKey, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + dispatch(new \App\Jobs\EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: $selectionKey, + slotKey: null, + runId: (int) $run->getKey(), + )); + + return $run; + } + + /** + * @return array{ + * pages_fetched:int, + * items_observed_count:int, + * items_upserted_count:int, + * error_count:int, + * safety_stop_triggered:bool, + * safety_stop_reason:?string, + * error_code:?string, + * error_category:?string, + * error_summary:?string + * } + */ + public function sync(Tenant $tenant, EntraGroupSyncRun $run): array + { + $nowUtc = CarbonImmutable::now('UTC'); + + $policyType = $this->contracts->directoryGroupsPolicyType(); + $path = $this->contracts->directoryGroupsListPath(); + + $contract = $this->contracts->get($policyType); + $query = []; + + if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) { + $query['$select'] = $contract['allowed_select']; + } + + $pageSize = (int) config('directory_groups.page_size', 999); + if ($pageSize > 0) { + $query['$top'] = $pageSize; + } + + $sanitized = $this->contracts->sanitizeQuery($policyType, $query); + $query = $sanitized['query']; + + $maxPages = (int) config('directory_groups.safety_stop.max_pages', 200); + $maxRuntimeSeconds = (int) config('directory_groups.safety_stop.max_runtime_seconds', 600); + $deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds)); + + $pagesFetched = 0; + $observed = 0; + $upserted = 0; + + $safetyStopTriggered = false; + $safetyStopReason = null; + + $errorCode = null; + $errorCategory = null; + $errorSummary = null; + $errorCount = 0; + + $options = $tenant->graphOptions(); + $useQuery = $query; + $nextPath = $path; + + while ($nextPath) { + if (CarbonImmutable::now('UTC')->greaterThan($deadline)) { + $safetyStopTriggered = true; + $safetyStopReason = 'runtime_exceeded'; + + break; + } + + if ($pagesFetched >= $maxPages) { + $safetyStopTriggered = true; + $safetyStopReason = 'max_pages_exceeded'; + + break; + } + + $response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]); + + if ($response->failed()) { + [$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response); + $errorCount = 1; + + break; + } + + $pagesFetched++; + + $data = $response->data; + $pageItems = $data['value'] ?? (is_array($data) ? $data : []); + + if (is_array($pageItems)) { + foreach ($pageItems as $item) { + if (! is_array($item)) { + continue; + } + + $entraId = $item['id'] ?? null; + if (! is_string($entraId) || $entraId === '') { + continue; + } + + $displayName = $item['displayName'] ?? null; + $groupTypes = $item['groupTypes'] ?? null; + + $values = [ + 'display_name' => is_string($displayName) ? $displayName : $entraId, + 'group_types' => is_array($groupTypes) ? $groupTypes : [], + 'security_enabled' => (bool) ($item['securityEnabled'] ?? false), + 'mail_enabled' => (bool) ($item['mailEnabled'] ?? false), + 'last_seen_at' => $nowUtc, + ]; + + EntraGroup::query()->updateOrCreate([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => $entraId, + ], $values); + + $observed++; + $upserted++; + } + } + + $nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null; + + if (! is_string($nextLink) || $nextLink === '') { + break; + } + + $nextPath = $this->stripGraphBaseUrl($nextLink); + $useQuery = []; + } + + $retentionDays = (int) config('directory_groups.retention_days', 90); + if ($retentionDays > 0) { + $cutoff = $nowUtc->subDays($retentionDays); + + EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->whereNotNull('last_seen_at') + ->where('last_seen_at', '<', $cutoff) + ->delete(); + } + + return [ + 'pages_fetched' => $pagesFetched, + 'items_observed_count' => $observed, + 'items_upserted_count' => $upserted, + 'error_count' => $errorCount, + 'safety_stop_triggered' => $safetyStopTriggered, + 'safety_stop_reason' => $safetyStopReason, + 'error_code' => $errorCode, + 'error_category' => $errorCategory, + 'error_summary' => $errorSummary, + ]; + } + + private function requestWithRetry(string $method, string $path, array $options): GraphResponse + { + $maxRetries = (int) config('directory_groups.safety_stop.max_retries', 8); + $maxRetries = max(0, $maxRetries); + + for ($attempt = 0; $attempt <= $maxRetries; $attempt++) { + $response = $this->graph->request($method, $path, $options); + + if ($response->successful()) { + return $response; + } + + $status = (int) ($response->status ?? 0); + + if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) { + return $response; + } + + $baseDelaySeconds = min(30, 1 << $attempt); + $jitterMillis = random_int(0, 250); + usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000); + } + + return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]); + } + + /** + * @return array{0:string,1:string,2:string} + */ + private function categorizeError(GraphResponse $response): array + { + $status = (int) ($response->status ?? 0); + + if (in_array($status, [401, 403], true)) { + return ['permission_denied', 'permission', 'Graph permission denied for groups listing.']; + } + + if ($status === 429) { + return ['throttled', 'throttling', 'Graph throttled the groups listing request.']; + } + + if (in_array($status, [500, 502, 503, 504], true)) { + return ['graph_unavailable', 'transient', 'Graph returned a transient server error.']; + } + + return ['graph_request_failed', 'unknown', 'Graph request failed.']; + } + + private function stripGraphBaseUrl(string $nextLink): string + { + $base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/') + .'/'.trim((string) config('graph.version', 'v1.0'), '/'); + + if (str_starts_with($nextLink, $base)) { + return ltrim((string) substr($nextLink, strlen($base)), '/'); + } + + return ltrim($nextLink, '/'); + } +} diff --git a/app/Services/Graph/GraphContractRegistry.php b/app/Services/Graph/GraphContractRegistry.php index 22f85c9..467899b 100644 --- a/app/Services/Graph/GraphContractRegistry.php +++ b/app/Services/Graph/GraphContractRegistry.php @@ -6,6 +6,18 @@ class GraphContractRegistry { + public function directoryGroupsPolicyType(): string + { + return 'directoryGroups'; + } + + public function directoryGroupsListPath(): string + { + $resource = $this->resourcePath($this->directoryGroupsPolicyType()) ?? 'groups'; + + return '/'.ltrim($resource, '/'); + } + /** * @return array */ diff --git a/config/directory_groups.php b/config/directory_groups.php new file mode 100644 index 0000000..7577957 --- /dev/null +++ b/config/directory_groups.php @@ -0,0 +1,25 @@ + (int) env('DIRECTORY_GROUPS_STALENESS_DAYS', 30), + + 'retention_days' => (int) env('DIRECTORY_GROUPS_RETENTION_DAYS', 90), + + 'page_size' => (int) env('DIRECTORY_GROUPS_PAGE_SIZE', 999), + + 'schedule' => [ + 'enabled' => (bool) env('DIRECTORY_GROUPS_SCHEDULE_ENABLED', true), + + // Daily scheduled sync (UTC). The dispatcher is still expected to run every minute. + 'time_utc' => (string) env('DIRECTORY_GROUPS_SCHEDULE_TIME_UTC', '02:00'), + + // Optional: limit scheduled dispatch to specific tenant ids/external ids (comma-separated). + 'tenants' => array_values(array_filter(array_map('trim', explode(',', (string) env('DIRECTORY_GROUPS_SCHEDULE_TENANTS', ''))))), + ], + + 'safety_stop' => [ + 'max_pages' => (int) env('DIRECTORY_GROUPS_MAX_PAGES', 200), + 'max_runtime_seconds' => (int) env('DIRECTORY_GROUPS_MAX_RUNTIME_SECONDS', 600), + 'max_retries' => (int) env('DIRECTORY_GROUPS_MAX_RETRIES', 8), + ], +]; diff --git a/config/graph_contracts.php b/config/graph_contracts.php index f1220a5..d140419 100644 --- a/config/graph_contracts.php +++ b/config/graph_contracts.php @@ -12,6 +12,11 @@ | */ 'types' => [ + 'directoryGroups' => [ + 'resource' => 'groups', + 'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'], + 'allowed_expand' => [], + ], 'deviceConfiguration' => [ 'resource' => 'deviceManagement/deviceConfigurations', 'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'], diff --git a/config/intune_permissions.php b/config/intune_permissions.php index 21684c1..609aedc 100644 --- a/config/intune_permissions.php +++ b/config/intune_permissions.php @@ -72,7 +72,7 @@ 'key' => 'Group.Read.All', 'type' => 'application', 'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.', - 'features' => ['assignments', 'group-mapping', 'backup-metadata'], + 'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache'], ], [ 'key' => 'DeviceManagementScripts.ReadWrite.All', diff --git a/database/factories/EntraGroupFactory.php b/database/factories/EntraGroupFactory.php new file mode 100644 index 0000000..ffc04a6 --- /dev/null +++ b/database/factories/EntraGroupFactory.php @@ -0,0 +1,28 @@ + + */ +class EntraGroupFactory extends Factory +{ + protected $model = EntraGroup::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'entra_id' => fake()->uuid(), + 'display_name' => fake()->company(), + 'group_types' => [], + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now('UTC'), + ]; + } +} diff --git a/database/factories/EntraGroupSyncRunFactory.php b/database/factories/EntraGroupSyncRunFactory.php new file mode 100644 index 0000000..9e5757c --- /dev/null +++ b/database/factories/EntraGroupSyncRunFactory.php @@ -0,0 +1,45 @@ + + */ +class EntraGroupSyncRunFactory extends Factory +{ + protected $model = EntraGroupSyncRun::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => User::factory(), + 'pages_fetched' => 0, + 'items_observed_count' => 0, + 'items_upserted_count' => 0, + 'error_count' => 0, + 'safety_stop_triggered' => false, + 'safety_stop_reason' => null, + 'error_code' => null, + 'error_category' => null, + 'error_summary' => null, + 'started_at' => null, + 'finished_at' => null, + ]; + } + + public function scheduled(): static + { + return $this->state(fn (): array => [ + 'initiator_user_id' => null, + ]); + } +} diff --git a/database/migrations/2026_01_11_120003_create_entra_groups_table.php b/database/migrations/2026_01_11_120003_create_entra_groups_table.php new file mode 100644 index 0000000..8aa06a7 --- /dev/null +++ b/database/migrations/2026_01_11_120003_create_entra_groups_table.php @@ -0,0 +1,36 @@ +id(); + + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->uuid('entra_id'); + $table->string('display_name'); + $table->jsonb('group_types')->nullable(); + $table->boolean('security_enabled')->default(false); + $table->boolean('mail_enabled')->default(false); + + $table->timestampTz('last_seen_at')->nullable(); + + $table->timestamps(); + + $table->unique(['tenant_id', 'entra_id']); + $table->index(['tenant_id', 'display_name']); + $table->index(['tenant_id', 'last_seen_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('entra_groups'); + } +}; diff --git a/database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php b/database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php new file mode 100644 index 0000000..72606ed --- /dev/null +++ b/database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php @@ -0,0 +1,51 @@ +id(); + + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + + $table->string('selection_key'); + $table->string('slot_key')->nullable(); + + $table->string('status'); + + $table->string('error_code')->nullable(); + $table->string('error_category')->nullable(); + $table->text('error_summary')->nullable(); + + $table->boolean('safety_stop_triggered')->default(false); + $table->string('safety_stop_reason')->nullable(); + + $table->unsignedInteger('pages_fetched')->default(0); + $table->unsignedInteger('items_observed_count')->default(0); + $table->unsignedInteger('items_upserted_count')->default(0); + $table->unsignedInteger('error_count')->default(0); + + $table->foreignId('initiator_user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestampTz('started_at')->nullable(); + $table->timestampTz('finished_at')->nullable(); + + $table->timestamps(); + + $table->index(['tenant_id', 'selection_key']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'finished_at']); + $table->unique(['tenant_id', 'selection_key', 'slot_key']); + }); + } + + public function down(): void + { + Schema::dropIfExists('entra_group_sync_runs'); + } +}; diff --git a/resources/views/filament/infolists/entries/restore-results.blade.php b/resources/views/filament/infolists/entries/restore-results.blade.php index 96a6b81..8171bde 100644 --- a/resources/views/filament/infolists/entries/restore-results.blade.php +++ b/resources/views/filament/infolists/entries/restore-results.blade.php @@ -13,6 +13,26 @@ $foundationItems = collect($results)->filter($isFoundationEntry); $policyItems = collect($results)->reject($isFoundationEntry); } + + $tenant = rescue(fn () => \App\Models\Tenant::current(), null); + $groupLabelResolver = $tenant ? app(\App\Services\Directory\EntraGroupLabelResolver::class) : null; + + $formatGroupId = function ($groupId, $fallbackName = null) use ($tenant, $groupLabelResolver) { + if (! is_string($groupId) || $groupId === '') { + return null; + } + + $cachedName = null; + + if ($tenant && $groupLabelResolver) { + $cached = $groupLabelResolver->lookupMany($tenant, [$groupId]); + $cachedName = $cached[strtolower($groupId)] ?? null; + } + + $name = is_string($fallbackName) && $fallbackName !== '' ? $fallbackName : null; + + return \App\Services\Directory\EntraGroupLabelResolver::formatLabel($cachedName ?? $name, $groupId); + }; @endphp @if ($foundationItems->isEmpty() && $policyItems->isEmpty()) @@ -152,12 +172,15 @@ }; $assignmentGroupId = $outcome['group_id'] ?? ($outcome['assignment']['target']['groupId'] ?? null); + $assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null); + $mappedGroupId = $outcome['mapped_group_id'] ?? null; + $mappedGroupLabel = $formatGroupId(is_string($mappedGroupId) ? $mappedGroupId : null); @endphp
- Assignment {{ $assignmentGroupId ?? 'unknown group' }} + Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
{{ $outcomeStatus }} @@ -166,7 +189,7 @@ @if (! empty($outcome['mapped_group_id']))
- Mapped to: {{ $outcome['mapped_group_id'] }} + Mapped to: {{ $mappedGroupLabel ?? $outcome['mapped_group_id'] }}
@endif diff --git a/resources/views/filament/modals/entra-group-cache-picker.blade.php b/resources/views/filament/modals/entra-group-cache-picker.blade.php new file mode 100644 index 0000000..3475afb --- /dev/null +++ b/resources/views/filament/modals/entra-group-cache-picker.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/livewire/bulk-operation-progress.blade.php b/resources/views/livewire/bulk-operation-progress.blade.php index faf75fb..864d872 100644 --- a/resources/views/livewire/bulk-operation-progress.blade.php +++ b/resources/views/livewire/bulk-operation-progress.blade.php @@ -3,6 +3,8 @@ @if($runs->isNotEmpty())
@foreach ($runs as $run) + @php($effectiveTotal = max((int) $run->total_items, (int) $run->processed_items)) + @php($percent = $effectiveTotal > 0 ? min(100, round(($run->processed_items / $effectiveTotal) * 100)) : 0)
@@ -38,17 +40,17 @@
- {{ $run->processed_items }} / {{ $run->total_items }} + {{ $run->processed_items }} / {{ $effectiveTotal }}
- {{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}% + {{ $percent }}%
+ style="width: {{ $percent }}%">
diff --git a/resources/views/livewire/entra-group-cache-picker-table.blade.php b/resources/views/livewire/entra-group-cache-picker-table.blade.php new file mode 100644 index 0000000..b7a760b --- /dev/null +++ b/resources/views/livewire/entra-group-cache-picker-table.blade.php @@ -0,0 +1,3 @@ +
+ {{ $this->table }} +
diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 5a593c4..65006bf 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -66,21 +66,24 @@ {{ $typeName }} @if($groupId) + @php + $groupLabel = $groupLabels[$groupId] ?? \App\Services\Directory\EntraGroupLabelResolver::formatLabel( + is_string($groupName) ? $groupName : null, + (string) $groupId, + ); + @endphp : @if($groupOrphaned) - ⚠️ Unknown group (ID: {{ $groupId }}) + ⚠️ {{ $groupLabel }} - @elseif($groupName) + @elseif($groupLabel) - {{ $groupName }} - - - ({{ $groupId }}) + {{ $groupLabel }} @else - Group ID: {{ $groupId }} + {{ $groupId }} @endif @endif diff --git a/routes/console.php b/routes/console.php index f2ce44a..49eb3f5 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,3 +9,4 @@ })->purpose('Display an inspiring quote'); Schedule::command('tenantpilot:schedules:dispatch')->everyMinute(); +Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute(); diff --git a/specs/051-entra-group-directory-cache/checklists/pr-gate.md b/specs/051-entra-group-directory-cache/checklists/pr-gate.md new file mode 100644 index 0000000..fb281ae --- /dev/null +++ b/specs/051-entra-group-directory-cache/checklists/pr-gate.md @@ -0,0 +1,79 @@ +# PR Gate Checklist: Entra Group Directory Cache (Groups v1) + +**Purpose**: PR review gate to validate that the *requirements* in `spec.md` are complete, clear, consistent, measurable, and cover key scenarios for Groups v1. +**Created**: 2026-01-11 +**Feature**: specs/051-entra-group-directory-cache/spec.md + +**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements. + +## Requirement Completeness + +- [x] CHK001 Are manual and scheduled start modes explicitly specified as in-scope (and not deferred)? [OK, Evidence: Spec §FR-004b, Spec §Pinned Decisions (v1 defaults)] +- [x] CHK002 Are the cached data fields (metadata-only) explicitly enumerated (e.g., displayName, groupTypes, securityEnabled/mailEnabled) rather than described generically? [OK, Evidence: Spec §FR-001a] +- [x] CHK003 Is the full-tenant scope of enumeration explicitly stated as the v1 source of truth (and not “referenced-only”)? [OK, Evidence: Spec §Clarifications, Spec §FR-004a] +- [x] CHK004 Are retention and purge requirements fully specified, including the retention start point and purge trigger? [OK, Evidence: Spec §FR-005a] +- [x] CHK005 Are audit trail requirements defined beyond “auditable” (what is recorded, where it is visible, minimum fields)? [OK, Evidence: Spec §FR-009a] +- [x] CHK006 Are authorization requirements defined for who may (a) browse groups, (b) trigger sync, (c) view run details? [OK, Evidence: Spec §Authorization & Access (Groups v1)] + +## Requirement Clarity + +- [x] CHK007 Is “periodic schedule” quantified with an explicit default cadence and configuration surface (per-tenant vs global)? [OK, Evidence: Spec §FR-004b (configurable per environment), Spec §Pinned Decisions (v1 defaults)] +- [x] CHK008 Is “deterministic selection identifier” defined with an unambiguous string/format and stability rules (per tenant + scope versioning)? [OK, Evidence: Spec §FR-004] +- [x] CHK009 Is “deduplicated” behavior specified (e.g., reject/skip when an active run exists; idempotency window), rather than implied? [OK, Evidence: Spec §User Story 1 Acceptance #6, Plan §Idempotency & Concurrency] +- [x] CHK010 Is “stale” defined with a specific comparison rule (e.g., $lastSeenAt < now - N days) and timezone/clock assumptions? [OK, Evidence: Spec §Clarifications (UTC everywhere), Spec §FR-005] +- [x] CHK011 Is the “unresolved fallback” label format and truncation rule specified consistently (e.g., last 8 chars, ellipsis placement)? [OK, Evidence: Spec §User Story 3 Acceptance #1–#2] +- [x] CHK012 Is “no live directory calls during render” defined operationally (what counts as render-time, allowed background refresh vs forbidden UI-triggered calls)? [OK, Evidence: Spec §FR-006a] + +## Requirement Consistency + +- [x] CHK013 Do the Clarifications (app-only auth, metadata-only, full scope) align with the Functional Requirements without contradiction? [OK, Evidence: Spec §Clarifications, Spec §FR-001a, Spec §FR-004a, Spec §FR-011a] +- [x] CHK014 Are the acceptance scenarios for US1 aligned with FR-003 (async only) and FR-002 (run record), without missing a required attribute? [OK, Evidence: Spec §User Story 1 Acceptance, Spec §FR-002, Spec §FR-003] +- [x] CHK015 Do US2 filters (stale) align with FR-005 and FR-007 (what filters are mandatory vs optional)? [OK, Evidence: Spec §User Story 2 Acceptance, Spec §FR-005, Spec §FR-007] +- [x] CHK016 Is the “read-only to Entra” rule consistent across spec (no implied writes or membership reads)? [OK, Evidence: Spec §FR-001, Spec §FR-001a] +- [ ] CHK017 Are success criteria thresholds consistent with scope and constraints (e.g., SC-003 95% resolution vs metadata-only cache + tenant scope)? [Consistency, Spec §SC-003, Spec §FR-001a, Spec §FR-004a] + +## Acceptance Criteria Quality + +- [ ] CHK018 Are acceptance scenarios written with objective outcomes (observable run record fields, counts, timestamps), not subjective terms? [Measurability, Spec §User Story 1 Acceptance] +- [x] CHK019 Do acceptance scenarios specify what constitutes “partial” completion vs “failed” (and expected operator-visible fields)? [OK, Evidence: Spec §FR-002a, Spec §FR-011] +- [x] CHK020 Are the success criteria (SC-001..SC-004) measurable with defined measurement method and scope (which pages, which tenants, sampling window)? [OK, Evidence: Spec §Success Criteria → Measurable Outcomes (SC-001..SC-004)] +- [ ] CHK021 Is the staleness default (N=30) defined as a requirement and stated as configurable (including configuration boundary: env/tenant)? [Measurability, Spec §FR-005] + +## Scenario Coverage + +- [x] CHK022 Are the primary flows covered end-to-end: start sync → run record → cache updated → browse/search → resolve label? [OK, Evidence: Spec §User Story 1–3 Acceptance] +- [x] CHK023 Are scheduled-run scenarios fully defined beyond cadence (initiator identity, operator visibility, and dedupe rules), rather than only mentioned? [OK, Evidence: Spec §Scheduled Sync Semantics (SS-001..SS-003), Spec §User Story 1 Acceptance #5–#7] +- [x] CHK024 Are multi-tenant isolation scenarios explicitly addressed (prevent cross-tenant reads in browse and label resolution)? [OK, Evidence: Spec §FR-010, Spec §Authorization & Access (Groups v1)] +- [x] CHK025 Are “no prior sync” scenarios specified (empty cache UI/labels, guidance to run sync)? [OK, Evidence: Spec §User Story 2 Acceptance #3, Spec §User Story 3 Acceptance #4] + +## Edge Case Coverage + +- [x] CHK026 Are throttling/partial results requirements specified with concrete behavior (retry/backoff, max duration, run status outcome)? [OK, Evidence: Spec §FR-011b, Spec §Edge Cases, Spec §CR-002a, Spec §FR-002a] +- [x] CHK027 Are permission-missing scenarios specified with operator-facing guidance (what the UI shows, what operators should do) beyond a stable error categorization? [OK, Evidence: Spec §FR-011c, Spec §Edge Cases] +- [x] CHK028 Is the behavior specified when a group disappears and later reappears within the 90-day window (last_seen update vs new record)? [OK, Evidence: Spec §FR-005b, Spec §FR-005a] +- [x] CHK029 Is paging/large-tenant behavior specified with bounding rules (max pages, safety stop, counters semantics)? [OK, Evidence: Spec §CR-002a, Spec §FR-002b] + +## Non-Functional Requirements + +- [ ] CHK030 Are performance requirements quantified for large tenants (sync duration, DB query latency, UI list responsiveness)? [Gap, Spec §Edge Cases, Spec §Technical Context] +- [ ] CHK031 Are logging/observability requirements defined (what to log, redaction rules, correlation IDs) for sync runs? [Gap, Spec §FR-009, Spec §FR-011] +- [x] CHK032 Are data minimization and privacy constraints explicitly stated for cached group metadata (e.g., no PII beyond displayName)? [OK, Evidence: Spec §FR-001a, Spec §Pinned Decisions (v1 defaults) Scope boundary] +- [ ] CHK033 Are accessibility requirements specified for the “Directory → Groups” UI (keyboard, table filtering, screen reader labels) if UI is in scope? [Gap, Spec §US2] + +## Dependencies & Assumptions + +- [x] CHK034 Are external dependencies explicitly pinned: required Graph permissions (e.g., Group.Read.All), endpoint family, and version (v1.0/beta)? [OK, Evidence: Spec §Pinned Decisions (v1 defaults), Spec §FR-011a, Spec §Contract Requirements] +- [ ] CHK035 Are Graph contract registry requirements sufficiently specific (contract key/name, allowed selects, expected fields) to be implementable without guesswork? [Completeness, Spec §Contract Requirements] +- [ ] CHK036 Are operational prerequisites specified (queue worker, scheduler/cron, retention job timing) including what happens if they’re missing? [Gap, Spec §Assumptions, Spec §US1 Acceptance] + +## Ambiguities & Conflicts + +- [ ] CHK037 Is the scope of “name resolution across the suite” explicitly bounded (which modules/pages are in v1 vs future)? [Completeness, Spec §Out of Scope (Groups v1)] +- [ ] CHK038 Is the relationship between cached group data and the existing live Graph-based resolution behavior explicitly defined (migration/transition expectations)? [Gap, Spec §FR-006, Spec §FR-008] +- [x] CHK039 Are error categories defined as an explicit controlled vocabulary (permission/throttling/transient/unknown) with mapping criteria? [OK, Evidence: Spec §FR-011] + +## Notes + +- Mark items as complete: `[x]` +- Use inline notes to capture identified gaps and propose spec edits +- This PR gate validates requirements quality (not implementation) diff --git a/specs/051-entra-group-directory-cache/checklists/requirements.md b/specs/051-entra-group-directory-cache/checklists/requirements.md new file mode 100644 index 0000000..3c11095 --- /dev/null +++ b/specs/051-entra-group-directory-cache/checklists/requirements.md @@ -0,0 +1,56 @@ +# Requirements Checklist (Evidence-Based): Entra Group Directory Cache (Groups v1) + +**Purpose**: Implementation gate for Feature 051. Only mark items `[x]` when there is explicit evidence in spec/plan/tasks (or existing repo conventions). Any remaining `[ ]` items must include a concrete follow-up reference (task ID). +**Created**: 2026-01-11 +**Feature**: specs/051-entra-group-directory-cache/spec.md + +## Evidence Sources + +- Spec: specs/051-entra-group-directory-cache/spec.md +- Plan: specs/051-entra-group-directory-cache/plan.md +- Tasks: specs/051-entra-group-directory-cache/tasks.md +- Contracts: specs/051-entra-group-directory-cache/contracts/ +- PR gate (requirements quality): specs/051-entra-group-directory-cache/checklists/pr-gate.md + +## Spec Hardened (Prereq for planning) + +- [x] CHK001 Pinned defaults exist (cadence, auth mode, required permission, paging strategy, staleness/retention). Evidence: spec.md §Pinned Decisions (v1 defaults), spec.md §FR-004b, spec.md §Contract Requirements +- [x] CHK002 Scope boundaries are explicit (no membership/owners, no cross-tenant compare, no delegated tokens required for UI). Evidence: spec.md §Pinned Decisions (v1 defaults), spec.md §Out of Scope (Groups v1), spec.md §FR-001a +- [x] CHK003 Acceptance scenarios include dedupe and “no render-time Graph calls” guard requirement. Evidence: spec.md §User Story 1 Acceptance #6, spec.md §FR-006 + §FR-006a, spec.md §User Story 3 Acceptance #3 + +## Planning Readiness + +- [x] CHK004 Plan removes placeholder “ACTION REQUIRED” sections and contains concrete file paths and sequencing notes. Evidence: plan.md §Project Structure, plan.md §Definition of Done (per phase) +- [x] CHK005 Plan specifies run lifecycle fields + status semantics (pending/running/succeeded/failed/partial) and how counters are computed. Evidence: plan.md §Execution Model → Sync Run Lifecycle +- [x] CHK006 Plan specifies idempotency rule (one active run per tenant+selection) and dedupe window behavior. Evidence: plan.md §Execution Model → Idempotency & Concurrency + +## Contracts & Permissions + +- [x] CHK007 OpenAPI admin surfaces exist for list/detail/sync/runs. Evidence: contracts/admin-directory-groups.openapi.yaml +- [x] CHK008 Graph contract registry entry for groups exists (endpoint + allowed selects). Evidence: config/graph_contracts.php (directoryGroups) +- [x] CHK009 Tenant permission catalog mentions directory-groups feature tagging for Group.Read.All. Evidence: config/intune_permissions.php (Group.Read.All features) + +## Data Model & Retention + +- [x] CHK010 Data model defines EntraGroup + EntraGroupSyncRun, key fields, indexes, and retention rules. Evidence: data-model.md +- [x] CHK011 Migrations exist for groups + runs with tenant scoping and unique constraints. Evidence: database/migrations/2026_01_11_120003_create_entra_groups_table.php + database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php + +## Implementation Tasks Defined + +- [x] CHK012 Task breakdown exists and is grouped by user story with dependencies. Evidence: tasks.md +- [x] CHK013 Feature config exists for staleness/retention/schedule/page_size. Evidence: config/directory_groups.php + +## Test Gate (Pest) + +- [x] CHK014 Tests are explicitly required and enumerated per story (including no-Graph-on-render test). Evidence: tasks.md T010–T013, tasks.md T022, tasks.md T026–T027 +- [x] CHK015 Guard test is implemented to fail hard on Graph client invocation during render. Evidence: tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php + +## Operational Readiness + +- [x] CHK016 Operator workflow documented (manual + scheduled + verification bullets). Evidence: quickstart.md +- [x] CHK017 Scheduled dispatcher command exists and is wired in routes/console.php. Evidence: app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php + routes/console.php + +## Notes + +- This checklist is the implementation gate. Use specs/051-entra-group-directory-cache/checklists/pr-gate.md during PR review to validate *requirements quality*. +- For any unchecked item, keep the follow-up task reference current. diff --git a/specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml b/specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml new file mode 100644 index 0000000..b8063ab --- /dev/null +++ b/specs/051-entra-group-directory-cache/contracts/admin-directory-groups.openapi.yaml @@ -0,0 +1,135 @@ +openapi: 3.0.3 +info: + title: TenantPilot Admin - Directory Groups (v1) + version: 0.1.0 + description: | + Admin surfaces for tenant-scoped Entra groups cache (metadata only). + Rendering uses cached data only; sync runs read the directory via app-only auth. + +paths: + /admin/tenants/{tenantId}/directory/groups: + get: + summary: List cached groups + parameters: + - $ref: '#/components/parameters/TenantId' + - in: query + name: q + schema: { type: string } + description: Search by display name or ID + - in: query + name: stale + schema: { type: boolean } + description: Filter to stale groups only + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EntraGroup' + + /admin/tenants/{tenantId}/directory/groups/{entraGroupId}: + get: + summary: Get group details + parameters: + - $ref: '#/components/parameters/TenantId' + - in: path + name: entraGroupId + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EntraGroup' + + /admin/tenants/{tenantId}/directory/groups/sync: + post: + summary: Start a manual groups sync + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '202': + description: Accepted (run created) + content: + application/json: + schema: + $ref: '#/components/schemas/EntraGroupSyncRun' + + /admin/tenants/{tenantId}/directory/groups/runs: + get: + summary: List group sync runs + parameters: + - $ref: '#/components/parameters/TenantId' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/EntraGroupSyncRun' + + /admin/tenants/{tenantId}/directory/groups/runs/{runId}: + get: + summary: Get group sync run details + parameters: + - $ref: '#/components/parameters/TenantId' + - in: path + name: runId + required: true + schema: { type: integer } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/EntraGroupSyncRun' + +components: + parameters: + TenantId: + in: path + name: tenantId + required: true + schema: { type: integer } + + schemas: + EntraGroup: + type: object + required: [tenant_id, entra_group_id, display_name, last_seen_at] + properties: + tenant_id: { type: integer } + entra_group_id: { type: string } + display_name: { type: string } + group_type: { type: string } + security_enabled: { type: boolean, nullable: true } + mail_enabled: { type: boolean, nullable: true } + group_types: + type: array + items: { type: string } + last_seen_at: { type: string, format: date-time } + + EntraGroupSyncRun: + type: object + required: [tenant_id, selection_key, status, created_at] + properties: + id: { type: integer } + tenant_id: { type: integer } + initiated_by_user_id: { type: integer, nullable: true } + selection_key: { type: string } + status: { type: string } + started_at: { type: string, format: date-time, nullable: true } + finished_at: { type: string, format: date-time, nullable: true } + observed_count: { type: integer, nullable: true } + upserted_count: { type: integer, nullable: true } + error_count: { type: integer, nullable: true } + error_category: { type: string, nullable: true } + error_summary: { type: string, nullable: true } + created_at: { type: string, format: date-time } diff --git a/specs/051-entra-group-directory-cache/data-model.md b/specs/051-entra-group-directory-cache/data-model.md new file mode 100644 index 0000000..47259d2 --- /dev/null +++ b/specs/051-entra-group-directory-cache/data-model.md @@ -0,0 +1,56 @@ +# Data Model: Entra Group Directory Cache (Groups v1) + +## Entities + +### EntraGroup +Represents a cached directory group for a single tenant (metadata only). + +**Fields (conceptual)** +- `id` (internal) +- `tenant_id` (FK) +- `entra_group_id` (GUID/string; external stable identifier) +- `display_name` (string) +- `group_type` (enum/string, derived; e.g., `Security`, `Microsoft365`, `Unknown`) +- `security_enabled` (bool, nullable) +- `mail_enabled` (bool, nullable) +- `group_types` (array/JSONB, nullable) +- `last_seen_at` (timestamp) +- `last_seen_run_id` (FK to sync run, optional) +- `created_at`, `updated_at` + +**Constraints & indexes** +- Unique: (`tenant_id`, `entra_group_id`) +- Index: (`tenant_id`, `display_name`) for search +- Index: (`tenant_id`, `last_seen_at`) for stale/retention filtering + +### EntraGroupSyncRun +Append-only record for one sync attempt. + +**Fields (conceptual)** +- `id` +- `tenant_id` (FK) +- `initiated_by_user_id` (FK nullable for scheduled) +- `selection_key` (string; deterministic for Groups v1 per tenant) +- `status` (enum: `pending`, `running`, `succeeded`, `failed`) +- `started_at`, `finished_at` +- counters: `observed_count`, `upserted_count`, `error_count` +- `error_category` (string/enum; e.g., `permission`, `throttling`, `transient`, `unknown`) +- `error_summary` (safe string) +- `created_at`, `updated_at` + +**Constraints & indexes** +- Index: (`tenant_id`, `created_at`) +- Index: (`tenant_id`, `status`) +- Optional unique/idempotency: (`tenant_id`, `selection_key`, `status in active`) in code using locks. + +## Relationships +- `Tenant` 1 → N `EntraGroup` +- `Tenant` 1 → N `EntraGroupSyncRun` +- `EntraGroupSyncRun` 1 → N `EntraGroup` (via `last_seen_run_id`, optional) + +## State transitions +- `pending` → `running` → `succeeded` | `failed` + +## Retention +- Purge rule: delete `EntraGroup` where `last_seen_at` < now - 90 days. +- Stale classification: stale if `last_seen_at` < now - N days (default N = 30). diff --git a/specs/051-entra-group-directory-cache/plan.md b/specs/051-entra-group-directory-cache/plan.md new file mode 100644 index 0000000..055513d --- /dev/null +++ b/specs/051-entra-group-directory-cache/plan.md @@ -0,0 +1,141 @@ +# Implementation Plan: Entra Group Directory Cache (Groups v1) + +**Branch**: `051-entra-group-directory-cache` | **Date**: 2026-01-11 | **Spec**: [specs/051-entra-group-directory-cache/spec.md](spec.md) +**Input**: Feature specification from `/specs/051-entra-group-directory-cache/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Provide a tenant-scoped Entra ID Groups metadata cache (no membership/owners) populated by queued sync runs (manual + scheduled), so the admin UI and other modules can resolve group IDs to friendly names without making live directory calls during page render. + +## Technical Context + +**Language/Version**: PHP 8.4 (Laravel 12) +**Primary Dependencies**: Filament v4, Livewire v3, Microsoft Graph integration via internal `GraphClientInterface` +**Storage**: PostgreSQL +**Testing**: Pest v4 +**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy) +**Project Type**: web +**Performance Goals**: background sync handles large tenants via paging; UI list/search remains responsive with DB indexes +**Constraints**: no live directory calls during page render; app-only Graph auth; safe error summaries; strict tenant isolation; idempotent runs; retention purge after 90 days last-seen +**Scale/Scope**: full-tenant group enumeration; plan for up to ~100k groups/tenant for v1 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: cache is “last observed” directory state; no snapshot payloads. +- Read/write separation: read-only to the directory; only internal DB writes. +- Graph contract path: directory group list endpoint/fields/permissions must be added to `config/graph_contracts.php` and accessed via `GraphClientInterface`. +- Deterministic capabilities: selection identifier for groups sync is deterministic (stable per tenant + groups-v1). +- Tenant isolation: all cached groups + run records tenant-scoped; no cross-tenant access paths. +- Automation: manual + scheduled sync runs use locks/idempotency, run records, safe error codes; handle 429/503 with backoff+jitter. +- Data minimization: store group metadata only (no membership/owners); logs contain no secrets/tokens. + +**Gate status (pre-Phase 0)**: PASS (no violations). Re-check after Phase 1 design. + +**Gate status (post-Phase 1)**: PASS (design artifacts present: research.md, data-model.md, contracts/*, quickstart.md). + +## Project Structure + +### Documentation (this feature) + +```text +specs/051-entra-group-directory-cache/ +├── plan.md # This file (/speckit.plan command output) +├── research.md # Phase 0 output (/speckit.plan command) +├── data-model.md # Phase 1 output (/speckit.plan command) +├── quickstart.md # Phase 1 output (/speckit.plan command) +├── contracts/ # Phase 1 output (/speckit.plan command) +└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan) +``` + +### Source Code (repository root) +```text +app/ +├── Filament/ +│ ├── Pages/ +│ └── Resources/ +├── Jobs/ +├── Models/ +├── Services/ +│ ├── Graph/ +│ └── Directory/ +└── Support/ + +config/ +├── graph.php +└── graph_contracts.php + +database/ +└── migrations/ + +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel web application. Implement directory group sync + cache as tenant-scoped Models, Services, Jobs, Filament pages/resources, migrations, and Pest tests. + +## Key Implementation Decisions (Pinned) + +- **Schedule default**: daily at **02:00 UTC** (environment default) + manual sync always available. +- **Auth mode**: app-only (service principal). UI must not require delegated tokens. +- **Permission**: `Group.Read.All` (application). +- **Graph strategy (v1)**: `GET /groups` with `$select=id,displayName,groupTypes,securityEnabled,mailEnabled` + paging via `@odata.nextLink`. +- **Retention**: stale threshold default 30 days; purge after 90 days since `last_seen_at`. +- **Render safety**: fail-hard test ensuring no Graph client calls during render. + +## Execution Model + +### Sync Run Lifecycle + +- Run statuses: `pending` → `running` → `succeeded` | `failed` (optionally `partial` if we intentionally record partial completion). +- Each run records: initiator (nullable for scheduled), timestamps, observed/upserted/error counters, and a safe error summary + category. + +### Idempotency & Concurrency + +- Deterministic selection key for v1: `groups-v1:all`. +- Deduplication rule: at most one active run per tenant + selection key. +- Scheduled dispatcher must be idempotent per tenant and schedule slot (no duplicate run creation). + +### Error Categorization + +- Supported categories: `permission`, `throttling`, `transient`, `unknown`. +- Store only safe-to-display summaries (no secrets/tokens). + +## Definition of Done (per phase) + +### Phase 2 (Foundational) + +- Migrations created for EntraGroup and EntraGroupSyncRun. +- Models + factories exist and are tenant-scoped. +- Graph contract registry has a groups directory read contract. + +### Phase 3 (US1) + +- Manual sync creates a run and dispatches a job. +- Job pages through Graph, upserts groups, updates counters, and enforces retention purge. +- Filament run list/detail exists; scheduler dispatch is wired. +- Pest tests for run creation, upsert behavior, retention purge, and scheduled run dispatch are green. + +### Phase 4 (US2) + +- Directory → Groups list/detail exists with search + stale filter + type filter. +- All browsing renders from DB cache only. + +### Phase 5 (US3) + +- Shared label resolver resolves from DB cache and provides consistent fallback formatting. +- Key pages render friendly labels without Graph calls. +- Fail-hard render guard test is green. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +N/A (no constitution violations anticipated) diff --git a/specs/051-entra-group-directory-cache/quickstart.md b/specs/051-entra-group-directory-cache/quickstart.md new file mode 100644 index 0000000..e00e5c6 --- /dev/null +++ b/specs/051-entra-group-directory-cache/quickstart.md @@ -0,0 +1,42 @@ +# Quickstart: Entra Group Directory Cache (Groups v1) + +## Goal +Populate a tenant-scoped cache of Entra ID groups (metadata only) via background sync runs, enabling safe UI rendering without live directory calls. + +## Prerequisites +- Graph integration configured (client credentials / app-only) +- Database migrated (feature migrations applied) +- Queue worker running for jobs (required for sync) +- Scheduler/cron running for periodic sync (optional, if enabled) + +### Local (Sail) quick commands + +- Bring containers up: `./vendor/bin/sail up -d` +- Run migrations: `./vendor/bin/sail artisan migrate` +- Run the queue worker (separate terminal): `./vendor/bin/sail artisan queue:work` +- Run the scheduler loop (separate terminal, optional): `./vendor/bin/sail artisan schedule:work` + +## Operator workflow (manual) +1. Switch into a tenant workspace. +2. Go to **Directory → Group Sync Runs**. +3. Click **Sync Groups** (creates/reuses a run and dispatches a background job). +4. Open the run and wait for status to complete. +5. Go to **Directory → Groups** and confirm list/search/detail are populated from the cache. + +Notes +- Search is cached-only and typically requires at least 2 characters. +- If the cache is empty, the Groups list will be empty until a sync completes. + +## Scheduled sync +- Ensure the scheduler is running (cron or `schedule:work`). +- Verify **Directory → Group Sync Runs** shows runs with empty Initiator (scheduled). +- The dispatcher command is `tenantpilot:directory-groups:dispatch` (configured to run every minute). + +## Verification +- UI pages that show group IDs render using cached data only. +- Unresolved IDs show a clear fallback. +- Groups not seen for >90 days are eventually purged. + +Suggested validation checks +- `./vendor/bin/sail artisan migrate:status | grep entra_group` +- `./vendor/bin/sail artisan schedule:list | grep directory-groups` diff --git a/specs/051-entra-group-directory-cache/research.md b/specs/051-entra-group-directory-cache/research.md new file mode 100644 index 0000000..c626673 --- /dev/null +++ b/specs/051-entra-group-directory-cache/research.md @@ -0,0 +1,42 @@ +# Research: Entra Group Directory Cache (Groups v1) + +## Decisions + +### Decision: Full tenant scope sync (all groups) +- Rationale: Ensures browse/search is complete and name-resolution has the best chance of resolving IDs across modules. +- Alternatives considered: + - Only sync referenced groups (creates gaps, hard to debug, non-deterministic coverage). + +### Decision: Start modes = manual + scheduled +- Rationale: Manual sync supports operator workflows (pre-restore/triage), while scheduled sync keeps cache fresh without relying on user action. +- Alternatives considered: + - Manual only (cache freshness depends on operator discipline). + - Scheduled only (harder to react quickly during ops). + +### Decision: App-only (service principal) directory reads +- Rationale: Scheduled runs must not depend on an interactive user session; app-only is simpler to audit/lock down. +- Alternatives considered: + - Delegated tokens (breaks scheduled runs, unpredictable auth state). + +### Decision: Cache metadata only (no membership/owners) +- Rationale: Solves the UX problem (name resolution + browse) with minimal data/PII exposure and lower API volume. +- Alternatives considered: + - Cache membership/owners (larger data surface, higher sensitivity, more Graph calls). + +### Decision: Missing groups retention and purge +- Decision: Retain groups for 90 days after `last_seen_at`, then purge. +- Rationale: Preserves investigatory value and audit context while bounding DB growth. +- Alternatives considered: + - Immediate delete (breaks historical triage; creates sudden “unresolved” labels). + - Retain forever (unbounded growth). + +### Decision: Graph access patterns (paging + retry) +- Decision: Use Graph list endpoint with paging; apply retry/backoff for 429/503; persist stable error categories in run record. +- Rationale: Full-tenant enumeration can be large; throttling is expected. +- Alternatives considered: + - Best-effort without retry (unreliable coverage). + - UI-time lookups (explicitly disallowed by spec). + +## Open Questions (resolved for planning) + +- Which specific Graph endpoint and permission names to use will be documented in the contract registry during implementation. diff --git a/specs/051-entra-group-directory-cache/spec.md b/specs/051-entra-group-directory-cache/spec.md new file mode 100644 index 0000000..04315e9 --- /dev/null +++ b/specs/051-entra-group-directory-cache/spec.md @@ -0,0 +1,236 @@ +# Feature Specification: Entra Group Directory Cache (Groups v1) + +**Feature Branch**: `051-entra-group-directory-cache` +**Created**: 2026-01-11 +**Status**: Draft +**Input**: User description: "Tenant-scoped Entra ID Groups cache (read-only), populated by queued sync runs, used for name-resolution across the suite; UI must render from DB-only (no live directory calls)." + +## Clarifications + +### Session 2026-01-11 + +- Q: What is the scope of the Groups v1 sync source? → A: All groups in the tenant. +- Q: How is the Groups sync started (MVP)? → A: Manual + scheduled/periodic. +- Q: What happens in the cache when a group is not returned on the next full sync? → A: Retain for 90 days after last seen, then purge. +- Q: In which auth mode does TenantAtlas read groups for sync runs? → A: App-only / service principal. +- Q: What data is cached in Groups v1? → A: Group metadata only (no membership/owners). +- Q: What timezone/clock semantics apply for staleness/retention comparisons? → A: UTC everywhere. +- Q: How are sync run statuses defined (partial vs failed)? → A: Partial only if some pages processed and at least one upsert occurred; otherwise failed. +- Q: What paging “safety stop” bounds apply to full-tenant group enumeration (v1)? → A: Max 200 pages, max 10 minutes, abort on retry exhaustion; record safety-stop reason + counters. + +## Pinned Decisions (v1 defaults) + +These defaults are intentionally “hard” for Groups v1 to avoid interpretability during planning/implementation. + +- **Schedule cadence default**: Scheduled sync runs daily at **02:00 UTC** (environment default). Manual sync is always available. +- **Auth mode (required)**: App-only (service principal). UI MUST NOT require delegated tokens. +- **Required Graph permission (application)**: `Group.Read.All`. +- **Graph API family**: Must work with Microsoft Graph **v1.0** semantics (no beta-only features required). +- **Paging strategy (v1)**: Full listing with `@odata.nextLink` paging. Delta sync is explicitly deferred to a future version. +- **Staleness default**: A group is “stale” if `last_seen_at < now() - 30 days` (computed in **UTC**, configurable per environment). +- **Retention/purge default**: Retain unseen groups for **90 days after last_seen_at** (computed in **UTC**), then purge. +- **Scope boundary**: No group membership/owners caching; no cross-tenant compare/promotion work inside this feature. + +## Authorization & Access (Groups v1) + +TenantPilot roles are tenant-scoped. Unless stated otherwise, all access below is limited to the active tenant context. + +- **Browse cached groups (Directory → Groups)**: allowed for roles `Owner`, `Manager`, `Operator`, `Readonly`. +- **View group sync runs**: allowed for roles `Owner`, `Manager`, `Operator`, `Readonly`. +- **Start manual sync**: allowed for roles `Owner`, `Manager`, `Operator`. +- **Cross-tenant access**: forbidden (no browsing or resolution of another tenant’s cached groups). + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Sync groups into a tenant-scoped cache (Priority: P1) + +As a tenant admin, I can trigger a background sync that stores the latest observed Entra groups into a tenant-scoped cache, so the suite can display stable, human-friendly group names without relying on live directory lookups. + +**Why this priority**: Without a cache, most assignment-heavy workflows become hard to use and hard to troubleshoot (only group IDs). + +**Independent Test**: Trigger a sync for a tenant and verify that a run record exists and that group rows become available for that tenant. + +**Acceptance Scenarios**: + +1. **Given** I am in a tenant workspace, **When** I start a “Sync Groups” operation, **Then** a run is created and the sync executes asynchronously (request returns without waiting). +2. **Given** I start a groups sync for a tenant, **When** the sync completes successfully, **Then** the cache reflects the full set of groups in that tenant as of that run. +3. **Given** a sync run completes successfully, **When** I browse groups for that tenant, **Then** I see group entries with display names and stable identifiers. +4. **Given** a sync run completes partially or fails, **When** I view the run record, **Then** I can see status and a safe error summary that helps triage. +5. **Given** scheduled sync is enabled, **When** the schedule triggers, **Then** a run is created and executed without manual intervention and is visible to operators. +6. **Given** a groups sync is already running for the same tenant and selection, **When** I start another sync, **Then** the request is deduplicated (no second concurrent run is created) and I can identify the already-active run. +7. **Given** a scheduled sync run is created, **When** I view the run record, **Then** it is clearly identified as “scheduled/system-initiated” (no interactive user session required). + +--- + +### User Story 2 - Browse groups (Priority: P2) + +As a tenant admin, I can browse, search, and filter cached groups so I can quickly resolve group IDs to names and validate whether expected groups exist in the tenant. + +**Why this priority**: Operators need a direct “source of truth (as last seen)” surface to debug restore mapping, dependencies, and drift findings. + +**Independent Test**: After a sync run, open the groups list and verify search/filter/detail views work using only cached data. + +**Acceptance Scenarios**: + +1. **Given** groups have been synced, **When** I open “Directory → Groups”, **Then** I can search by display name and open a group detail view. +2. **Given** some groups were not observed recently, **When** I filter for “stale” groups, **Then** I see only groups whose last-seen timestamp is older than the staleness threshold. +3. **Given** no groups have been synced yet, **When** I open “Directory → Groups”, **Then** I see a clear empty-state that explains the cache is empty and offers a “Sync Groups” action. + +--- + +### User Story 3 - Name resolution across the suite (Priority: P3) + +As an operator, when other pages reference group IDs (dependencies, restore mapping, drift, compare), the UI shows a friendly label if the group exists in the cache; otherwise it shows a clear “unresolved” fallback. + +**Why this priority**: This turns the cache into a foundational building block used across modules while keeping rendering safe and predictable. + +**Independent Test**: Load a page that includes a group GUID reference and verify it renders with a name if present, and with a fallback if not present—without making any live directory calls during rendering. + +**Acceptance Scenarios**: + +1. **Given** a page references a group GUID that exists in the cache, **When** I view the page, **Then** I see `Group: (…last8)` (or equivalent) derived from cached data. +2. **Given** a page references a group GUID that does not exist in the cache, **When** I view the page, **Then** I see `Group (unresolved): …last8` (or equivalent) and the page still renders. +3. **Given** I view any page that renders group labels, **When** the page renders, **Then** it MUST NOT make any live directory calls (no Graph requests during render-time), and automated tests MUST fail hard if a Graph client is invoked. +4. **Given** no groups have been synced yet, **When** a page renders a group GUID reference, **Then** it still renders with the unresolved fallback (and does not attempt live directory lookup during render). + +--- + +### Edge Cases + +- **Throttling / transient failures (retry policy)**: + - Retryable conditions for Graph reads: HTTP `429`, `503`, and network timeouts. + - Non-retryable (fail-fast): HTTP `403` (permission), and other non-2xx responses unless explicitly categorized as retryable. + - Backoff strategy: exponential backoff with jitter (full jitter), capped. + - Max retries (total per run): 8 (aligned with safety stop `retry_exhausted`). + - If retries are exhausted: run MUST abort with `safety_stop_triggered=true`, `safety_stop_reason=retry_exhausted`, and status MUST follow FR-002a / CR-002a (partial if any upserts, else failed). + - Operator-visible: run record MUST show `error_category=throttling` (or `transient` for timeouts), and a safe summary including retry count and last HTTP status (if available). + +- **Permission missing (forbidden)**: + - If Graph returns HTTP `403` on group list, the run MUST stop immediately (no retries), with `error_category=permission`, and status MUST be `failed`. + - Operator-visible guidance MUST be explicit: `Group.Read.All` (application permission) missing and/or admin consent missing. + +- **Groups disappear / reappear**: + - If a group is not observed, it is retained per FR-005a and may still resolve labels until purged. + - If the same group ID reappears within the retention window, the record MUST be updated and `last_seen_at` refreshed. + +- **Large tenants**: + - Runs MUST respect CR-002a safety-stop bounds (max pages / max runtime) to prevent runaway cost and load. + - UI browse/search MUST operate on cached DB data only and rely on indexes (no live Graph during render-time). + +## Out of Scope (Groups v1) + +- Caching group **membership** or **owners**. +- Any UI behavior that requires delegated Graph tokens for group name resolution. +- Cross-tenant compare/promotion of groups. +- Delta-sync based directory change tracking. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any external directory calls or any write/change behavior, +the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests. + +### Assumptions & Dependencies + +- The application already has a tenant context concept; this cache is scoped strictly to the active tenant. +- Background processing infrastructure exists (queue worker) so sync can run asynchronously. +- The tenant has (or can be granted) sufficient directory read permissions to list groups. +- Directory reads for group sync run using app-only (service principal) permissions. +- Required Graph permission for Groups v1 is `Group.Read.All` (application permission). +- Groups v1 does not require Graph beta-only capabilities. +- Other modules that display group references can integrate via a shared “group name resolution” capability. +- Groups v1 sync scope is all groups in the tenant (not only groups already referenced by TenantAtlas). +- The system can execute scheduled background work (e.g., cron/scheduler) to run periodic group sync. +- The system can enforce retention/purge for cached groups that have not been observed for a configured period. +- Groups v1 cache stores group metadata only and does not store group membership or owners. + +### Functional Requirements + +- **FR-001 (Tenant-scoped cache)**: System MUST store a tenant-scoped cache of Entra groups in the application, and it MUST be read-only with respect to Entra. +- **FR-001a (Cached fields; no membership caching)**: Groups v1 caching MUST store only group metadata needed for name resolution and browsing, and MUST NOT store group membership or owners. Cached fields for v1 MUST include: `id`, `displayName`, `groupTypes`, `securityEnabled`, `mailEnabled`, and `last_seen_at`. +- **FR-002 (Sync runs)**: System MUST create an append-only “sync run” record for each sync attempt, including lifecycle status and basic counters (observed/upserted/errors). +- **FR-002a (Status semantics: partial vs failed)**: The run MUST use explicit statuses: `pending`, `running`, `succeeded`, `failed`, `partial`. Criteria: + - `succeeded`: all pages processed to completion. + - `partial`: at least one page was processed AND `upserted_count > 0`, but the run did not complete successfully (e.g., aborted due to repeated throttling, transient faults, or other non-fatal error conditions). + - `failed`: zero progress (no processed pages) OR `upserted_count = 0` due to a fatal condition (e.g., missing permission) or immediate abort. + The run record MUST include `error_category` (see FR-011) and a safe `error_summary` when status is `failed` or `partial`. +- **FR-002b (Run record: paging + safety stop fields)**: The run record MUST include `pages_fetched`, `items_observed_count`, `items_upserted_count`, and (when applicable) `safety_stop_triggered` plus `safety_stop_reason`. +- **FR-003 (Async only)**: Starting a groups sync MUST dispatch background work and MUST NOT perform full sync work in the initiating HTTP request. +- **FR-004 (Idempotent selection)**: System MUST support a deterministic “selection identifier” for the v1 groups sync scope so repeated requests with the same selection can be recognized and deduplicated. For Groups v1, the selection identifier MUST be the stable string `groups-v1:all`. +- **FR-004a (Full tenant scope)**: For Groups v1, the sync MUST attempt to enumerate all groups visible in the tenant scope (subject to permissions and provider limits). +- **FR-004b (Start modes + cadence default)**: The system MUST support starting groups sync both manually (operator-initiated) and on a periodic schedule. The default scheduled cadence for Groups v1 MUST be daily at 02:00 UTC (configurable per environment). +- **FR-005 (Staleness)**: System MUST track when a group was last observed and MUST allow identifying “stale” groups as “not observed for N days”; for v1, default N = 30 and configurable per environment. +- **FR-005 (Staleness)**: System MUST track when a group was last observed and MUST allow identifying “stale” groups as “not observed for N days”; for v1, default N = 30 and configurable per environment. All comparisons MUST be computed in **UTC**. +- **FR-005a (Retention & purge)**: If a group is not observed in subsequent full-tenant syncs, the system MUST retain the cached record for 90 days after its last observed timestamp (`last_seen_at`), and it MUST purge the record after that retention window. All comparisons MUST be computed in **UTC**. +- **FR-005b (Disappear / reappear semantics)**: The cache MUST behave deterministically when a group disappears and later reappears: + - If a group is not observed in a run, its cached row MUST remain unchanged (including `last_seen_at`) until either it is observed again or it is purged per FR-005a. + - If the same group `id` is observed again **within** the retention window, the system MUST update the existing row (refresh `displayName`, flags/types as returned, and set `last_seen_at` to the run’s observation time). + - If the group was already purged (retention window elapsed) and later reappears, the system MUST create a new cached row for that group `id` on observation. +- **FR-006 (UI safety + guard test)**: UI rendering for directory groups and for name resolution MUST use cached data only (no live directory calls at render time). The feature MUST include a test that fails hard if the Graph client is invoked during render. +- **FR-006a (Definition: render-time)**: “Render-time” means the synchronous request lifecycle that produces UI output (Filament pages, Livewire component renders, and any server-side code executed to build the response). During render-time, the system MUST NOT call Microsoft Graph for group data. Background work (queued jobs, scheduled commands) MAY call Graph. +- **FR-007 (Search & filters)**: Users MUST be able to search cached groups by name and filter by at least “stale vs. fresh” and “group type” (security vs. M365 group) when that info is available. +- **FR-008 (Cross-module resolution)**: When other modules reference a group ID, the system MUST resolve it to a friendly label from the cache when available, and MUST show a clear unresolved fallback when not available. +- **FR-009 (Audit & observability)**: Starting a sync and completing/failing a sync MUST be auditable (who initiated, when, status), and the operator MUST be able to view the run record. +- **FR-009a (Audit minimum fields + visibility)**: Each sync attempt MUST produce (1) a sync run record visible in the admin UI, and (2) audit entries for start and finish/failure visible in the tenant audit log. Minimum audit metadata for these entries: `tenant`, `action`, `status`, `run_id`, `selection_key`, initiator identity (user id or “system”), and on completion: `observed_count`, `upserted_count`, `error_count`, `error_category` (if any). +- **FR-010 (Tenant isolation)**: All group cache data and sync run data MUST be strictly tenant-scoped; cross-tenant access MUST be prevented by authorization. +- **FR-011 (Error hygiene + categories)**: Failure details stored for runs MUST be safe to display (no secrets), and SHOULD be summarized into stable categories. For v1, the supported categories are: `permission`, `throttling`, `transient`, `unknown`. +- **FR-011b (Retry & backoff policy; v1)**: The sync implementation MUST apply a consistent retry policy for Graph group listing: + - Retryable: HTTP `429`, `503`, and network timeouts. + - Backoff: exponential backoff with jitter (full jitter), capped at 60 seconds per delay. + - Max retries: 8 total per run. + - On retry exhaustion: abort per CR-002a (`retry_exhausted`) and set status per FR-002a. +- **FR-011c (Permission-missing operator UX)**: When a run fails due to missing permissions (HTTP `403`), the operator-facing UI MUST display a stable error code and guidance. + - Error code: `graph_forbidden`. + - Guidance: “Grant `Group.Read.All` (application permission) and admin consent for the tenant, then retry.” +- **FR-011d (Throttling/transient operator UX)**: When a run aborts due to throttling/transient retry exhaustion, the operator-facing UI MUST display a stable warning/error code and a safe summary. + - Error/warning code: `graph_throttled` for repeated `429/503`; `graph_timeout` for timeouts. + - Summary MUST include: retry count (up to 8) and whether the safety stop was triggered. +- **FR-011a (Auth mode + required permission)**: Directory reads for groups sync MUST use app-only (service principal) authorization and MUST NOT depend on an interactive user session. The required Graph permission is `Group.Read.All` (application). +- **FR-012 (Tests)**: The feature MUST include automated tests covering tenant isolation, basic sync-run lifecycle persistence, and “UI pages render without live directory calls,” including a fail-hard guard test that asserts the Graph client is not invoked during render. + +### Scheduled Sync Semantics + +- **SS-001 (Initiator identity)**: Scheduled sync runs MUST be recorded as system-initiated (no user initiator). +- **SS-002 (Visibility)**: Operators MUST be able to distinguish scheduled vs manual runs when viewing run records. +- **SS-003 (Schedule dedupe)**: Scheduled dispatch MUST NOT create duplicate runs for the same tenant + selection in the same schedule slot. If a run for the current slot already exists (or an active run is in progress), the dispatcher MUST skip creating a second run. + +### Contract Requirements + +- **CR-001 (Graph contract registry)**: The feature MUST register the Groups v1 directory read contract in the Graph contract registry. +- **CR-002 (List endpoint + select fields)**: Sync MUST read groups via `GET /groups` with `$select=id,displayName,groupTypes,securityEnabled,mailEnabled` and MUST page via `@odata.nextLink` until completion (or a documented safety stop). +- **CR-002a (Safety stop: bounds + abort criteria)**: Sync MUST enforce safety-stop bounds for Groups v1 to prevent runaway runs: + - **Max pages**: 200 pages per run. + - **Max runtime**: 10 minutes per run. + - **Abort criteria (immediate)**: stop the run if runtime exceeds max runtime, pages exceed max pages, or the Graph client exceeds 8 total retries for retryable throttling/transient conditions (e.g., repeated 429/503) (“retry exhausted”). + - **Abort status**: if `items_upserted_count > 0`, mark run as `partial`; otherwise mark run as `failed`. + - **Run record**: set `safety_stop_triggered=true` and set `safety_stop_reason` to one of: `max_pages`, `max_runtime`, `retry_exhausted`. +- **CR-003 (Delta strategy deferred)**: Delta endpoints/strategies MUST NOT be used in Groups v1. + +### Key Entities *(include if feature involves data)* + +- **EntraGroup**: A tenant-scoped cached record representing an Entra group (external ID, display name, group type/flags when available, last observed timestamp). Groups v1 stores metadata only. +- **EntraGroupSyncRun**: A tenant-scoped run record representing one attempt to sync groups (status, timestamps, counters, safe error summary, initiator). +- **GroupReference**: A cross-module reference to a group by ID that can be resolved (or not) via the cache. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (Resolve time)**: For a tenant with cached groups, operators can resolve a group ID to a human-friendly label in under 30 seconds. + - **Measured from**: UI workflow time (manual stopwatch) for either (a) Directory → Groups search + open detail OR (b) in-context label rendering. + - **Scope/window**: 95th percentile over 20 attempts on a representative tenant with cached groups. + - **Pass/fail reporting**: recorded as a QA note for the release gate. + +- **SC-002 (Render resilience)**: Directory/Groups pages render successfully even when the external directory API is unavailable. + - **Measured from**: server-side request completion (HTTP 200) while the sync job’s Graph calls are failing/blocked (e.g., simulated outage), demonstrating the UI does not depend on live Graph during render-time. + - **Scope/window**: 20 page loads across Directory → Groups list and detail. + - **Pass/fail reporting**: QA note; must remain true for all future pages integrating the label resolver. + +- **SC-003 (Label resolution rate)**: After a successful sync, at least 95% of group GUID references on supported pages resolve to a friendly label. + - **Measured from**: page output inspection against the cached DB state (resolved label present vs unresolved fallback) for a representative tenant. + - **Scope/window**: sample of supported pages + a set of group GUID references observed in the last successful run; target is 95% resolved. + - **Pass/fail reporting**: QA note with sample size and tenant used. + +- **SC-004 (End-to-end operator workflow time)**: Operators can complete “Sync Groups → Verify group exists → Use group in mapping” in under 3 minutes. + - **Measured from**: UI workflow time (manual stopwatch) plus sync run duration from DB. + - **Scope/window**: 95th percentile over the last 20 runs per tenant + selection key (`groups-v1:all`). + - **Reporting requirement**: run detail UI MUST show `started_at`, `finished_at`, computed `duration_seconds`, and counters (`items_observed_count`, `items_upserted_count`, `error_count`). diff --git a/specs/051-entra-group-directory-cache/tasks.md b/specs/051-entra-group-directory-cache/tasks.md new file mode 100644 index 0000000..cc928f1 --- /dev/null +++ b/specs/051-entra-group-directory-cache/tasks.md @@ -0,0 +1,184 @@ +--- + +description: "Task list for implementing Entra Group Directory Cache (Groups v1)" +--- + +# Tasks: Entra Group Directory Cache (Groups v1) + +**Input**: Design documents from `/specs/051-entra-group-directory-cache/` +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/ + +**Tests**: Required (Pest), per FR-012 in `spec.md`. + +**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently. + +## Format: `- [ ] T### [P?] [US#?] Description (with file path)` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[US#]**: User story mapping (US1/US2/US3) + +## Path Conventions (Laravel) + +- App code: `app/` +- Config: `config/` +- DB: `database/migrations/`, `database/factories/` +- Filament admin: `app/Filament/Resources/` +- Console + scheduler wiring: `app/Console/Commands/`, `routes/console.php` +- Tests (Pest): `tests/Feature/`, `tests/Unit/` + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Introduce feature configuration and permission metadata. + +- [x] T001 Create feature config defaults in config/directory_groups.php (staleness_days=30, retention_days=90, schedule enabled/interval, page_size) +- [x] T002 Update Group.Read.All feature tagging in config/intune_permissions.php (include directory-groups / group-directory-cache) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Database schema + core domain objects required by all user stories. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [x] T003 Create migrations for Entra groups + sync runs in database/migrations/*_create_entra_groups_table.php and database/migrations/*_create_entra_group_sync_runs_table.php +- [x] T004 [P] Create EntraGroup model in app/Models/EntraGroup.php (tenant-scoped, casts for group_types) +- [x] T005 [P] Create EntraGroupSyncRun model in app/Models/EntraGroupSyncRun.php (status constants, counters, safe error fields) +- [x] T006 [P] Create EntraGroup factory in database/factories/EntraGroupFactory.php +- [x] T007 [P] Create EntraGroupSyncRun factory in database/factories/EntraGroupSyncRunFactory.php +- [x] T008 Add tenant relationships in app/Models/Tenant.php (entraGroups(), entraGroupSyncRuns()) +- [x] T009 Add directory-groups contract metadata in config/graph_contracts.php and accessor(s) in app/Services/Graph/GraphContractRegistry.php (select fields + base path for /groups) + +**Checkpoint**: Foundation ready — user stories can now proceed. + +--- + +## Phase 3: User Story 1 - Sync groups into a tenant-scoped cache (Priority: P1) 🎯 MVP + +**Goal**: Manual + scheduled async sync writes tenant-scoped group cache and run records (including safe error summaries). + +**Independent Test**: Trigger a sync for a tenant and verify a run record exists and group rows are populated for that tenant. + +### Tests for User Story 1 (REQUIRED) ⚠️ + +> Write these tests FIRST and ensure they fail before implementation. + +- [x] T010 [P] [US1] Add test for manual sync creating a run + dispatching a job in tests/Feature/DirectoryGroups/StartSyncTest.php +- [x] T011 [P] [US1] Add test that sync job upserts groups + updates counters in tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php +- [x] T012 [P] [US1] Add test that retention purge deletes groups older than retention window in tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php +- [x] T013 [P] [US1] Add test that scheduled dispatcher creates a run without a user initiator in tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php + +- [x] T014 [P] [US1] Implement deterministic selection key helper in app/Services/Directory/EntraGroupSelection.php +- [x] T015 [US1] Implement sync service in app/Services/Directory/EntraGroupSyncService.php (Graph paging via GraphClientInterface, upsert, counters, retention purge) +- [x] T016 [US1] Implement queued job in app/Jobs/EntraGroupSyncJob.php (run lifecycle: pending→running→succeeded/failed; safe error_category + error_summary) +- [x] T017 [US1] Add audit logging for sync start/completion in app/Jobs/EntraGroupSyncJob.php using app/Services/Intune/AuditLogger.php +- [x] T018 [P] [US1] Create Filament run resource in app/Filament/Resources/EntraGroupSyncRunResource.php and app/Filament/Resources/EntraGroupSyncRunResource/Pages/{ListEntraGroupSyncRuns,ViewEntraGroupSyncRun}.php +- [x] T019 [US1] Add “Sync Groups” action on List page in app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php (creates run, dispatches job, shows notification) +- [x] T020 [US1] Implement scheduled dispatcher command in app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php (idempotent per tenant + minute-slot, respects config/directory_groups.php) +- [x] T021 [US1] Register scheduler entry in routes/console.php for tenantpilot:directory-groups:dispatch (every minute) + +**Checkpoint**: US1 complete — cache population works, runs are visible, and scheduled runs can be observed. + +--- + +## Phase 4: User Story 2 - Browse groups (Priority: P2) + +**Goal**: Provide a cached-only “Directory → Groups” browse/search/filter/detail UI. + +**Independent Test**: After a sync run, open the groups list and verify search/filter/detail views work using only cached data. + +### Tests for User Story 2 (REQUIRED) ⚠️ + +- [x] T022 [P] [US2] Add test for listing/searching/filtering cached groups in tests/Feature/DirectoryGroups/BrowseGroupsTest.php + +### Implementation for User Story 2 + +- [x] T023 [P] [US2] Create Filament groups resource in app/Filament/Resources/EntraGroupResource.php and app/Filament/Resources/EntraGroupResource/Pages/{ListEntraGroups,ViewEntraGroup}.php +- [x] T024 [US2] Implement list query + filters (q search, stale filter, group type filter) in app/Filament/Resources/EntraGroupResource.php +- [x] T025 [US2] Add “Sync Groups” header action that links to run list or starts a sync in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php + +**Checkpoint**: US2 complete — operators can browse/search cached groups and triage staleness. + +--- + +## Phase 5: User Story 3 - Name resolution across the suite (Priority: P3) + +**Goal**: Any UI that displays group IDs shows a friendly cached label (or unresolved fallback) without live directory calls during render. + +**Independent Test**: Load a page that includes group GUID references and verify it renders with names from DB cache (and fallbacks when missing) without calling Graph. + +### Tests for User Story 3 (REQUIRED) ⚠️ + +- [x] T026 [P] [US3] Add unit test for label resolver formatting + fallbacks in tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php +- [x] T027 [P] [US3] Add feature test ensuring Tenant/Restore/PolicyVersion UI renders without Graph calls during render in tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php + +### Implementation for User Story 3 + +- [x] T028 [US3] Implement DB-backed label resolver in app/Services/Directory/EntraGroupLabelResolver.php (resolveOne/resolveMany, tenant-scoped, stable formatting) +- [x] T029 [US3] Refactor group label rendering in app/Filament/Resources/TenantResource.php to use EntraGroupLabelResolver instead of Graph lookups +- [x] T030 [US3] Refactor Livewire assignments widget to use cached labels: app/Livewire/PolicyVersionAssignmentsWidget.php and resources/views/livewire/policy-version-assignments-widget.blade.php +- [x] T031 [US3] Refactor restore results to show cached labels where possible: resources/views/filament/infolists/entries/restore-results.blade.php +- [x] T032 [US3] Refactor restore group-mapping inputs/labels to prefer cached labels: app/Filament/Resources/RestoreRunResource.php + +**Checkpoint**: US3 complete — name resolution is consistent and render-safe across key pages. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Validation, cleanup, and operational readiness. + +- [x] T033 [P] Run formatting on changed files with vendor/bin/pint --dirty +- [x] T034 Run targeted Pest suite for this feature (e.g., php artisan test tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups) +- [x] T035 Validate operator workflow against specs/051-entra-group-directory-cache/quickstart.md + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)** → blocks nothing, but should be done first. +- **Foundational (Phase 2)** → BLOCKS all user stories. +- **User Stories (Phase 3–5)** → depend on Foundational; proceed in priority order (US1 → US2 → US3). +- **Polish (Phase 6)** → depends on all desired user stories. + +### User Story Dependencies + +- **US1 (P1)**: Depends on Phase 2 only. +- **US2 (P2)**: Depends on US1 (needs data to browse). +- **US3 (P3)**: Depends on US1 (needs cache populated); can begin in parallel with US2 once US1 is stable. + +--- + +## Parallel Execution Examples + +### US1 + +- Parallel tests: T010–T013 +- Parallel foundational code: T014 and T018 can be developed alongside T015–T016 once schema/models exist. + +### US2 + +- T023 can start while T022 is being written (different files). + +### US3 + +- T028 can start while T026/T027 are being written; integration tasks T029–T032 can be split across different files. + +--- + +## Implementation Strategy + +### MVP (US1 only) + +1. Phase 1 → Phase 2 +2. Phase 3 (US1): tests → sync service/job → run UI → scheduler +3. Stop and validate via quickstart workflow + +### Incremental Delivery + +- Add US2 (browse) after US1 is stable. +- Add US3 (name resolution) after US1, then refactor page-by-page. diff --git a/tests/Feature/DirectoryGroups/BrowseGroupsTest.php b/tests/Feature/DirectoryGroups/BrowseGroupsTest.php new file mode 100644 index 0000000..d31cba6 --- /dev/null +++ b/tests/Feature/DirectoryGroups/BrowseGroupsTest.php @@ -0,0 +1,114 @@ +actingAs($user); + + $otherTenant = Tenant::factory()->create(); + + $stalenessDays = (int) config('directory_groups.staleness_days', 30); + + EntraGroup::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000001', + 'display_name' => 'Alpha Team', + 'group_types' => null, + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now(), + ]); + + EntraGroup::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000002', + 'display_name' => 'Beta Unified', + 'group_types' => ['Unified'], + 'security_enabled' => false, + 'mail_enabled' => true, + 'last_seen_at' => now()->subDays(max(1, $stalenessDays) + 1), + ]); + + EntraGroup::query()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000003', + 'display_name' => 'Other Tenant Group', + 'group_types' => null, + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now(), + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $extractNames = function ($livewire): Collection { + $records = $livewire->instance()->getTableRecords(); + + $items = method_exists($records, 'items') ? collect($records->items()) : collect($records); + + return $items->pluck('display_name'); + }; + + $names = $extractNames(Livewire::test(ListEntraGroups::class)); + expect($names)->toContain('Alpha Team'); + expect($names)->toContain('Beta Unified'); + expect($names)->not->toContain('Other Tenant Group'); + + $names = $extractNames( + Livewire::test(ListEntraGroups::class) + ->set('tableSearch', 'Beta') + ); + expect($names)->toContain('Beta Unified'); + expect($names)->not->toContain('Alpha Team'); + + $names = $extractNames( + Livewire::test(ListEntraGroups::class) + ->set('tableFilters.stale.value', 1) + ); + expect($names)->toContain('Beta Unified'); + expect($names)->not->toContain('Alpha Team'); + + $names = $extractNames( + Livewire::test(ListEntraGroups::class) + ->set('tableFilters.group_type.value', 'security') + ); + expect($names)->toContain('Alpha Team'); + expect($names)->not->toContain('Beta Unified'); +}); + +test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $groupB = EntraGroup::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'entra_id' => '00000000-0000-0000-0000-000000000099', + 'display_name' => 'Tenant B Group', + 'group_types' => null, + 'security_enabled' => true, + 'mail_enabled' => false, + 'last_seen_at' => now(), + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA)) + ->assertForbidden(); +}); diff --git a/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php new file mode 100644 index 0000000..dc67eb9 --- /dev/null +++ b/tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php @@ -0,0 +1,59 @@ +tenant = Tenant::factory()->create(); + $this->policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + ]); + + $this->user = User::factory()->create(); + $this->user->tenants()->syncWithoutDetaching([ + $this->tenant->getKey() => ['role' => 'owner'], + ]); +}); + +it('renders policy version view without any Graph calls during render', function () { + mock(GraphClientInterface::class) + ->shouldNotReceive('listPolicies') + ->shouldNotReceive('getPolicy') + ->shouldNotReceive('getOrganization') + ->shouldNotReceive('applyPolicy') + ->shouldNotReceive('getServicePrincipalPermissions') + ->shouldNotReceive('request'); + + $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', + ], + ], + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); + + $response->assertOk(); +}); diff --git a/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php new file mode 100644 index 0000000..7517372 --- /dev/null +++ b/tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php @@ -0,0 +1,38 @@ +create(); + + Config::set('directory_groups.schedule.enabled', true); + Config::set('directory_groups.schedule.time_utc', '02:00'); + + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC')); + + Artisan::call('tenantpilot:directory-groups:dispatch', [ + '--tenant' => [$tenant->tenant_id], + ]); + + $slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z'; + + $run = EntraGroupSyncRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('selection_key', 'groups-v1:all') + ->where('slot_key', $slotKey) + ->first(); + + expect($run)->not->toBeNull() + ->and($run->initiator_user_id)->toBeNull(); + + Queue::assertPushed(EntraGroupSyncJob::class); + + CarbonImmutable::setTestNow(); +}); diff --git a/tests/Feature/DirectoryGroups/StartSyncTest.php b/tests/Feature/DirectoryGroups/StartSyncTest.php new file mode 100644 index 0000000..982ea0a --- /dev/null +++ b/tests/Feature/DirectoryGroups/StartSyncTest.php @@ -0,0 +1,24 @@ +startManualSync($tenant, $user); + + expect($run)->toBeInstanceOf(EntraGroupSyncRun::class) + ->and($run->tenant_id)->toBe($tenant->getKey()) + ->and($run->initiator_user_id)->toBe($user->getKey()) + ->and($run->selection_key)->toBe('groups-v1:all') + ->and($run->status)->toBe(EntraGroupSyncRun::STATUS_PENDING); + + Queue::assertPushed(EntraGroupSyncJob::class); +}); diff --git a/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php new file mode 100644 index 0000000..df3f1d8 --- /dev/null +++ b/tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php @@ -0,0 +1,88 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + EntraGroup::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'entra_id' => '11111111-1111-1111-1111-111111111111', + 'display_name' => 'Old Name', + 'last_seen_at' => now('UTC')->subDays(10), + ]); + + $mock = \Mockery::mock(GraphClientInterface::class); + $mock->shouldReceive('request') + ->twice() + ->andReturn( + new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => '11111111-1111-1111-1111-111111111111', + 'displayName' => 'New Name', + 'groupTypes' => ['Unified'], + 'securityEnabled' => false, + 'mailEnabled' => true, + ], + ], + '@odata.nextLink' => 'https://graph.microsoft.com/v1.0/groups?$skiptoken=abc', + ], status: 200), + new GraphResponse(success: true, data: [ + 'value' => [ + [ + 'id' => '22222222-2222-2222-2222-222222222222', + 'displayName' => 'Second Group', + 'groupTypes' => [], + 'securityEnabled' => true, + 'mailEnabled' => false, + ], + ], + ], status: 200), + ); + + app()->instance(GraphClientInterface::class, $mock); + + $job = new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: 'groups-v1:all', + slotKey: null, + runId: (int) $run->getKey(), + ); + + $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); + + $run->refresh(); + + expect($run->status)->toBe(EntraGroupSyncRun::STATUS_SUCCEEDED) + ->and($run->pages_fetched)->toBe(2) + ->and($run->items_observed_count)->toBe(2) + ->and($run->items_upserted_count)->toBe(2) + ->and($run->error_count)->toBe(0) + ->and($run->finished_at)->not->toBeNull(); + + expect(EntraGroup::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2); + + $updated = EntraGroup::query() + ->where('tenant_id', $tenant->getKey()) + ->where('entra_id', '11111111-1111-1111-1111-111111111111') + ->first(); + + expect($updated)->not->toBeNull() + ->and($updated->display_name)->toBe('New Name') + ->and($updated->mail_enabled)->toBeTrue(); +}); diff --git a/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php new file mode 100644 index 0000000..da21325 --- /dev/null +++ b/tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php @@ -0,0 +1,51 @@ +create([ + 'tenant_id' => $tenant->getKey(), + 'last_seen_at' => now('UTC')->subDays(91), + ]); + + $newGroup = EntraGroup::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'last_seen_at' => now('UTC')->subDays(10), + ]); + + $run = EntraGroupSyncRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'status' => EntraGroupSyncRun::STATUS_PENDING, + 'initiator_user_id' => $user->getKey(), + ]); + + $mock = \Mockery::mock(GraphClientInterface::class); + $mock->shouldReceive('request') + ->once() + ->andReturn(new GraphResponse(success: true, data: ['value' => []], status: 200)); + app()->instance(GraphClientInterface::class, $mock); + + $job = new EntraGroupSyncJob( + tenantId: (int) $tenant->getKey(), + selectionKey: 'groups-v1:all', + slotKey: null, + runId: (int) $run->getKey(), + ); + + $job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class)); + + expect(EntraGroup::query()->whereKey($oldGroup->getKey())->exists())->toBeFalse(); + expect(EntraGroup::query()->whereKey($newGroup->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php new file mode 100644 index 0000000..e696853 --- /dev/null +++ b/tests/Feature/Filament/EntraGroupSyncRunResourceTest.php @@ -0,0 +1,98 @@ +create(); + $otherTenant = Tenant::factory()->create(); + + EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => 'slot-a', + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + ]); + + EntraGroupSyncRun::query()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => 'slot-b', + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + $otherTenant->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(EntraGroupSyncRunResource::getUrl('index', tenant: $tenant)) + ->assertOk() + ->assertSee('slot-a') + ->assertDontSee('slot-b'); +}); + +test('entra group sync run view is forbidden cross-tenant (403)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $runB = EntraGroupSyncRun::query()->create([ + 'tenant_id' => $tenantB->getKey(), + 'selection_key' => 'groups-v1:all', + 'slot_key' => null, + 'status' => EntraGroupSyncRun::STATUS_SUCCEEDED, + ]); + + $user = User::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $tenantA->getKey() => ['role' => 'owner'], + $tenantB->getKey() => ['role' => 'owner'], + ]); + + $this->actingAs($user) + ->get(EntraGroupSyncRunResource::getUrl('view', ['record' => $runB], tenant: $tenantA)) + ->assertForbidden(); +}); + +test('sync groups action enqueues job and writes database notification', function () { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::test(ListEntraGroupSyncRuns::class) + ->callAction('sync_groups'); + + Queue::assertPushed(EntraGroupSyncJob::class); + + $run = EntraGroupSyncRun::query()->where('tenant_id', $tenant->getKey())->latest('id')->first(); + expect($run)->not->toBeNull(); + + $this->assertDatabaseHas('notifications', [ + 'notifiable_id' => $user->getKey(), + 'notifiable_type' => $user->getMorphClass(), + 'type' => RunStatusChangedNotification::class, + ]); + + $notification = $user->notifications()->latest('id')->first(); + expect($notification)->not->toBeNull(); + expect($notification->data['actions'][0]['url'] ?? null) + ->toBe(EntraGroupSyncRunResource::getUrl('view', ['record' => $run->getKey()], tenant: $tenant)); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php index a07e792..b713bca 100644 --- a/tests/Feature/RestoreGroupMappingTest.php +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -202,3 +202,32 @@ 'action' => 'restore.group_mapping.applied', ]); }); + +test('restore wizard can fill a group mapping entry from directory cache picker', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $user = User::factory()->create(); + $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + Filament::setTenant($tenant, true); + + $sourceGroupId = fake()->uuid(); + $targetGroupId = fake()->uuid(); + + Livewire::test(CreateRestoreRun::class) + ->set('data.check_summary', 'old') + ->set('data.check_results', ['x']) + ->call('applyEntraGroupCachePick', sourceGroupId: $sourceGroupId, entraId: $targetGroupId) + ->assertSet("data.group_mapping.{$sourceGroupId}", $targetGroupId) + ->assertSet('data.check_summary', null) + ->assertSet('data.check_results', []); +}); diff --git a/tests/Unit/BulkOperationRunProgressTest.php b/tests/Unit/BulkOperationRunProgressTest.php index 0a9e71e..a36a66f 100644 --- a/tests/Unit/BulkOperationRunProgressTest.php +++ b/tests/Unit/BulkOperationRunProgressTest.php @@ -29,3 +29,33 @@ ->and($run->failures)->toBeArray() ->and($run->failures)->toHaveCount(1); }); + +test('bulk operation run total_items is at least the item_ids count', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['a', 'b', 'c', 'd'], 1); + + expect($run->total_items)->toBe(4); +}); + +test('bulk operation completion clamps total_items up to processed_items', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + $service = app(BulkOperationService::class); + $run = $service->createRun($tenant, $user, 'inventory', 'sync', ['only-one'], 1); + + $service->start($run); + $service->recordSuccess($run); + $service->recordSuccess($run); + + $run->refresh(); + expect($run->processed_items)->toBe(2)->and($run->total_items)->toBe(1); + + $service->complete($run); + $run->refresh(); + + expect($run->total_items)->toBe(2); +}); diff --git a/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php b/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php new file mode 100644 index 0000000..189f9eb --- /dev/null +++ b/tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php @@ -0,0 +1,50 @@ +toBe('Unresolved (…55555555)'); +}); + +it('resolves labels from the tenant cache (tenant-scoped)', function () { + $tenantA = Tenant::factory()->create(); + $tenantB = Tenant::factory()->create(); + + $entraId = '11111111-2222-3333-4444-555555555555'; + + EntraGroup::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'entra_id' => $entraId, + 'display_name' => 'Alpha Team', + ]); + + EntraGroup::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'entra_id' => $entraId, + 'display_name' => 'Beta Team', + ]); + + $resolver = app(EntraGroupLabelResolver::class); + + expect($resolver->resolveOne($tenantA, $entraId)) + ->toBe('Alpha Team (…55555555)') + ->and($resolver->resolveOne($tenantB, $entraId)) + ->toBe('Beta Team (…55555555)'); +}); + +it('returns a fallback without querying invalid UUIDs', function () { + $tenant = Tenant::factory()->create(); + + $resolver = app(EntraGroupLabelResolver::class); + + expect($resolver->resolveOne($tenant, 'group-123')) + ->toBe('Unresolved (group123)'); +});