diff --git a/app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php b/app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php new file mode 100644 index 0000000..6a45fa4 --- /dev/null +++ b/app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php @@ -0,0 +1,358 @@ + + */ + public array $runUrls = []; + + public function mount(): void + { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + $this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN); + + $activeSession = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('status', ['draft', 'in_progress']) + ->latest('id') + ->first(); + + if (! $activeSession instanceof OnboardingSession) { + $this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant)); + + return; + } + + $this->session = $activeSession; + + if ($activeSession->current_step < 4) { + $this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant)); + } + } + + /** + * @return array + */ + public function latestEvidenceStatusByTaskType(): array + { + $tenant = Tenant::current(); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('task_type', OnboardingTaskType::all()) + ->orderByDesc('recorded_at') + ->get(); + + $byTask = []; + + foreach ($evidence as $row) { + if (! isset($byTask[$row->task_type])) { + $byTask[$row->task_type] = $row->status; + } + } + + return $byTask; + } + + /** + * @return array + */ + public function latestEvidenceByTaskType(): array + { + $tenant = Tenant::current(); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('task_type', OnboardingTaskType::all()) + ->orderByDesc('recorded_at') + ->get(); + + $byTask = []; + + foreach ($evidence as $row) { + if (! isset($byTask[$row->task_type])) { + $byTask[$row->task_type] = $row; + } + } + + return $byTask; + } + + /** + * @return array, + * status: string, + * badge: \App\Support\Badges\BadgeSpec, + * evidence: OnboardingEvidence|null, + * prerequisites_met: bool, + * unmet_prerequisites: array, + * }> + */ + public function taskRows(): array + { + $statuses = $this->latestEvidenceStatusByTaskType(); + $evidenceByTask = $this->latestEvidenceByTaskType(); + + return collect(OnboardingTaskCatalog::all()) + ->map(function (array $task) use ($statuses, $evidenceByTask): array { + $taskType = $task['task_type']; + $status = $statuses[$taskType] ?? 'unknown'; + + $unmet = OnboardingTaskCatalog::unmetPrerequisites($taskType, $statuses); + + return [ + 'task_type' => $taskType, + 'title' => $task['title'], + 'step' => $task['step'], + 'prerequisites' => $task['prerequisites'], + 'status' => $status, + 'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $status), + 'evidence' => $evidenceByTask[$taskType] ?? null, + 'prerequisites_met' => count($unmet) === 0, + 'unmet_prerequisites' => $unmet, + ]; + }) + ->values() + ->all(); + } + + /** + * @return array + */ + public function fixHintsFor(?string $reasonCode): array + { + return OnboardingFixHints::forReason($reasonCode); + } + + /** + * @return array + */ + public function recentEvidenceRows(int $limit = 20): array + { + $tenant = Tenant::current(); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->orderByDesc('recorded_at') + ->limit($limit) + ->with('operationRun') + ->get(); + + return $evidence + ->map(function (OnboardingEvidence $row) use ($tenant): array { + $runUrl = null; + + if ($row->operationRun) { + $runUrl = OperationRunLinks::view($row->operationRun, $tenant); + } + + return [ + 'recorded_at' => $row->recorded_at?->toDateTimeString() ?? '', + 'task_type' => $row->task_type, + 'status' => $row->status, + 'badge' => BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, $row->status), + 'reason_code' => $row->reason_code, + 'message' => $row->message, + 'run_url' => $runUrl, + ]; + }) + ->values() + ->all(); + } + + public function startTask(string $taskType): void + { + if (! $this->canStartProviderTasks) { + abort(403); + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->session instanceof OnboardingSession) { + Notification::make() + ->title('No onboarding session') + ->danger() + ->send(); + + return; + } + + if ($this->session->current_step < 4) { + $this->redirect(TenantOnboardingWizard::getUrl(tenant: $tenant)); + + return; + } + + if (! in_array($taskType, OnboardingTaskType::all(), true)) { + Notification::make() + ->title('Unknown task') + ->danger() + ->send(); + + return; + } + + $latestStatuses = $this->latestEvidenceStatusByTaskType(); + + if (! OnboardingTaskCatalog::prerequisitesMet($taskType, $latestStatuses)) { + Notification::make() + ->title('Prerequisites not met') + ->body('Complete required tasks first.') + ->warning() + ->send(); + + return; + } + + $connectionId = $this->session->provider_connection_id; + + if (! is_int($connectionId)) { + Notification::make() + ->title('Select a provider connection first') + ->warning() + ->send(); + + return; + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->whereKey($connectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('Selected provider connection not found') + ->danger() + ->send(); + + return; + } + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $run = $runs->ensureRunWithIdentity( + tenant: $tenant, + type: $taskType, + identityInputs: ['task_type' => $taskType], + context: [ + 'task_type' => $taskType, + 'onboarding_session_id' => (int) $this->session->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + initiator: $user, + ); + + $this->runUrls[$taskType] = OperationRunLinks::view($run, $tenant); + + if (! $run->wasRecentlyCreated) { + Notification::make() + ->title('Task already queued') + ->body('A run is already queued or running. Use the link to monitor progress.') + ->warning() + ->send(); + + return; + } + + match ($taskType) { + OnboardingTaskType::VerifyPermissions => OnboardingVerifyPermissionsJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $this->session->getKey(), + operationRun: $run, + ), + OnboardingTaskType::ConsentStatus => OnboardingConsentStatusJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $this->session->getKey(), + operationRun: $run, + ), + OnboardingTaskType::ConnectionDiagnostics => OnboardingConnectionDiagnosticsJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $this->session->getKey(), + operationRun: $run, + ), + OnboardingTaskType::InitialSync => OnboardingInitialSyncJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $this->session->getKey(), + operationRun: $run, + ), + default => null, + }; + + Notification::make() + ->title('Task queued') + ->success() + ->send(); + } +} diff --git a/app/Filament/Pages/Onboarding/TenantOnboardingWizard.php b/app/Filament/Pages/Onboarding/TenantOnboardingWizard.php new file mode 100644 index 0000000..5960b4e --- /dev/null +++ b/app/Filament/Pages/Onboarding/TenantOnboardingWizard.php @@ -0,0 +1,863 @@ + + */ + public array $handoffUserOptions = []; + + public function mount(): void + { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + /** @var CapabilityResolver $resolver */ + $resolver = app(CapabilityResolver::class); + + $this->canStartProviderTasks = $resolver->can($user, $tenant, Capabilities::PROVIDER_RUN); + $this->canManageProviderConnections = $resolver->can($user, $tenant, Capabilities::PROVIDER_MANAGE); + $this->canManageTenant = $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE); + + $activeSession = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('status', ['draft', 'in_progress']) + ->latest('id') + ->first(); + + if (! $activeSession instanceof OnboardingSession && $this->canStartProviderTasks) { + $activeSession = OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'draft', + 'current_step' => 1, + 'assigned_to_user_id' => $user->getKey(), + 'metadata' => [], + ]); + } + + $this->session = $activeSession; + + $defaultConnectionId = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->where('is_default', true) + ->value('id'); + + $this->selectedProviderConnectionId = $this->session?->provider_connection_id + ?? (is_int($defaultConnectionId) ? $defaultConnectionId : null); + + $this->refreshCollaborationState(attemptAcquire: $this->canStartProviderTasks); + + if ($this->session instanceof OnboardingSession + && $this->hasSessionLock + && $this->session->provider_connection_id === null + && is_int($this->selectedProviderConnectionId) + && $this->tenantHasLegacyCredentials($tenant) + ) { + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->whereKey($this->selectedProviderConnectionId) + ->first(); + + if ($connection instanceof ProviderConnection) { + $this->session->update(['provider_connection_id' => $connection->getKey()]); + $this->session->refresh(); + } + } + } + + private function tenantHasLegacyCredentials(Tenant $tenant): bool + { + return trim((string) ($tenant->app_client_id ?? '')) !== '' + && trim((string) ($tenant->app_client_secret ?? '')) !== ''; + } + + /** + * @return array + */ + protected function getHeaderActions(): array + { + $tenant = Tenant::current(); + + return [ + UiEnforcement::forAction( + Action::make('takeover_onboarding_session') + ->label('Take over') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Take over onboarding session') + ->modalDescription('This will take over the onboarding session lock. Use when the current lock holder is unavailable.') + ->action(function (): void { + $this->takeoverSession(); + }), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply() + ->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->sessionLockedByOther), + + UiEnforcement::forAction( + Action::make('handoff_onboarding_session') + ->label('Handoff') + ->color('gray') + ->requiresConfirmation() + ->modalHeading('Handoff onboarding session') + ->modalDescription('Assign onboarding to another tenant member and release your lock.') + ->form([ + Select::make('assigned_to_user_id') + ->label('Assign to') + ->options(fn (): array => $this->handoffUserOptions) + ->searchable() + ->required(), + ]) + ->action(function (array $data): void { + $assignedToUserId = (int) ($data['assigned_to_user_id'] ?? 0); + $this->handoffSession($assignedToUserId); + }), + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply() + ->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock), + + Action::make('release_onboarding_lock') + ->label('Release lock') + ->color('gray') + ->visible(fn (): bool => $this->session instanceof OnboardingSession && $this->hasSessionLock) + ->requiresConfirmation() + ->modalHeading('Release onboarding lock') + ->modalDescription('This will release your lock so another user can take over onboarding.') + ->action(function (): void { + $this->releaseSessionLock(); + }), + + UiEnforcement::forAction( + Action::make('migrate_legacy_credentials') + ->label('Migrate legacy credentials') + ->color('warning') + ->requiresConfirmation() + ->modalHeading('Migrate legacy tenant credentials') + ->modalDescription('This will copy the tenant\'s legacy app client secret into the selected provider connection credentials. The secret is never displayed.') + ->action(function (): void { + $this->migrateLegacyCredentials(); + }), + ) + ->requireCapability(Capabilities::PROVIDER_MANAGE) + ->tooltip(UiTooltips::INSUFFICIENT_PERMISSION) + ->apply() + ->visible(fn (): bool => $this->canOfferLegacyCredentialMigration()), + ]; + } + + private function refreshCollaborationState(bool $attemptAcquire = false): void + { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + $this->hasSessionLock = false; + $this->sessionLockedByOther = false; + $this->sessionLockedByLabel = null; + $this->sessionLockedUntil = null; + $this->handoffUserOptions = []; + + if (! $this->session instanceof OnboardingSession) { + return; + } + + if ($this->canManageTenant) { + $this->handoffUserOptions = $tenant->users() + ->orderBy('name') + ->orderBy('email') + ->get(['users.id', 'users.name', 'users.email']) + ->mapWithKeys(function (User $member): array { + $label = trim((string) $member->name) !== '' + ? (string) $member->name + : (string) $member->email; + + if (trim((string) $member->email) !== '') { + $label .= ' <'.$member->email.'>'; + } + + return [(int) $member->getKey() => $label]; + }) + ->all(); + } + + if ($attemptAcquire) { + app(OnboardingLockService::class)->acquire($this->session, $user); + } + + $this->session->refresh(); + $this->session->loadMissing(['lockedBy']); + + $this->hasSessionLock = (int) ($this->session->locked_by_user_id ?? 0) === (int) $user->getKey() + && $this->session->locked_until?->isFuture(); + + $this->sessionLockedByOther = $this->isLockedByOther($this->session, $user); + + if ($this->sessionLockedByOther) { + $lockedBy = $this->session->lockedBy; + $this->sessionLockedByLabel = $lockedBy instanceof User + ? (trim((string) $lockedBy->name) !== '' ? (string) $lockedBy->name : (string) $lockedBy->email) + : 'another user'; + } + + $this->sessionLockedUntil = $this->session->locked_until?->diffForHumans(); + } + + private function ensureLockForMutation(): bool + { + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->session instanceof OnboardingSession) { + return false; + } + + $acquired = app(OnboardingLockService::class)->acquire($this->session, $user); + $this->refreshCollaborationState(attemptAcquire: false); + + if ($acquired) { + return true; + } + + Notification::make() + ->title('Session is locked') + ->body('Another user is currently editing onboarding. Take over the lock to make changes.') + ->warning() + ->send(); + + return false; + } + + private function isLockedByOther(OnboardingSession $session, User $user): bool + { + if ($session->locked_by_user_id === null || $session->locked_until === null) { + return false; + } + + if ($session->locked_until->isPast()) { + return false; + } + + return (int) $session->locked_by_user_id !== (int) $user->getKey(); + } + + private function takeoverSession(): void + { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->canManageTenant) { + abort(403); + } + + if (! $this->session instanceof OnboardingSession) { + return; + } + + $previousLockHolderId = $this->session->locked_by_user_id; + + app(OnboardingLockService::class)->takeover($this->session, $user); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'onboarding.takeover', + context: [ + 'onboarding_session_id' => (int) $this->session->getKey(), + 'previous_locked_by_user_id' => is_int($previousLockHolderId) ? $previousLockHolderId : null, + ], + actorId: (int) $user->getKey(), + actorEmail: $user->email, + actorName: $user->name, + status: 'success', + resourceType: 'onboarding_session', + resourceId: (string) $this->session->getKey(), + ); + + $this->refreshCollaborationState(attemptAcquire: false); + + Notification::make() + ->title('Lock taken over') + ->success() + ->send(); + } + + private function handoffSession(int $assignedToUserId): void + { + $tenant = Tenant::current(); + + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->canManageTenant) { + abort(403); + } + + if (! $this->session instanceof OnboardingSession) { + return; + } + + if (! $this->ensureLockForMutation()) { + return; + } + + $assignee = $tenant->users()->whereKey($assignedToUserId)->first(); + if (! $assignee instanceof User) { + Notification::make() + ->title('Assignee not found') + ->danger() + ->send(); + + return; + } + + $this->session->update(['assigned_to_user_id' => (int) $assignee->getKey()]); + + app(OnboardingLockService::class)->release($this->session, $user); + + app(AuditLogger::class)->log( + tenant: $tenant, + action: 'onboarding.handoff', + context: [ + 'onboarding_session_id' => (int) $this->session->getKey(), + 'assigned_to_user_id' => (int) $assignee->getKey(), + ], + actorId: (int) $user->getKey(), + actorEmail: $user->email, + actorName: $user->name, + status: 'success', + resourceType: 'onboarding_session', + resourceId: (string) $this->session->getKey(), + ); + + $this->refreshCollaborationState(attemptAcquire: false); + + Notification::make() + ->title('Onboarding handed off') + ->success() + ->send(); + } + + private function releaseSessionLock(): void + { + $user = auth()->user(); + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->session instanceof OnboardingSession) { + return; + } + + app(OnboardingLockService::class)->release($this->session, $user); + + $this->refreshCollaborationState(attemptAcquire: false); + + Notification::make() + ->title('Lock released') + ->success() + ->send(); + } + + private function canOfferLegacyCredentialMigration(): bool + { + if (! $this->session instanceof OnboardingSession) { + return false; + } + + $tenant = Tenant::current(); + + if (! $this->tenantHasLegacyCredentials($tenant)) { + return false; + } + + $connectionId = $this->session->provider_connection_id; + + if (! is_int($connectionId)) { + return false; + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->with('credential') + ->whereKey($connectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + return false; + } + + $credential = $connection->credential; + + if ($credential === null) { + return true; + } + + if ($credential->type !== 'client_secret') { + return false; + } + + $payload = $credential->payload; + if (! is_array($payload)) { + return true; + } + + $clientId = trim((string) Arr::get($payload, 'client_id')); + $clientSecret = trim((string) Arr::get($payload, 'client_secret')); + + return $clientId === '' || $clientSecret === ''; + } + + private function migrateLegacyCredentials(): void + { + if (! $this->canManageProviderConnections) { + abort(403); + } + + if (! $this->session instanceof OnboardingSession) { + return; + } + + if (! $this->ensureLockForMutation()) { + return; + } + + $tenant = Tenant::current(); + + $connectionId = $this->session->provider_connection_id; + if (! is_int($connectionId)) { + Notification::make() + ->title('Select a provider connection first') + ->warning() + ->send(); + + return; + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->whereKey($connectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('Selected provider connection not found') + ->danger() + ->send(); + + return; + } + + $outcome = app(LegacyTenantCredentialMigrator::class)->migrate($tenant, $connection); + + $this->refreshCollaborationState(attemptAcquire: false); + + Notification::make() + ->title($outcome['migrated'] ? 'Credentials migrated' : 'Migration not needed') + ->body($outcome['message']) + ->color($outcome['migrated'] ? 'success' : 'gray') + ->send(); + } + + /** + * @return array + */ + public function providerConnections(): array + { + $tenant = Tenant::current(); + + return ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->orderByDesc('is_default') + ->orderBy('provider') + ->orderBy('display_name') + ->get() + ->map(function (ProviderConnection $connection): array { + $entraName = Arr::get(is_array($connection->metadata) ? $connection->metadata : [], 'entra_tenant_name'); + $entraSuffix = is_string($entraName) && trim($entraName) !== '' ? ' — '.trim($entraName) : ''; + + $label = ($connection->display_name ?: ucfirst($connection->provider)) + .$entraSuffix + .($connection->is_default ? ' (default)' : ''); + + return [ + 'id' => (int) $connection->getKey(), + 'label' => $label, + ]; + }) + ->values() + ->all(); + } + + public function updatedSelectedProviderConnectionId(): void + { + if (! $this->canStartProviderTasks) { + abort(403); + } + + $tenant = Tenant::current(); + + if (! $this->session instanceof OnboardingSession) { + return; + } + + if (! $this->ensureLockForMutation()) { + return; + } + + if (! is_int($this->selectedProviderConnectionId)) { + $this->session->update(['provider_connection_id' => null]); + $this->session->refresh(); + + return; + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->whereKey($this->selectedProviderConnectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('Connection not found') + ->danger() + ->send(); + + return; + } + + $this->session->update([ + 'provider_connection_id' => $connection->getKey(), + ]); + $this->session->refresh(); + + $this->refreshCollaborationState(attemptAcquire: false); + + Notification::make() + ->title('Provider connection selected') + ->success() + ->send(); + } + + /** + * @return array}> + */ + public function planTasks(): array + { + return OnboardingTaskCatalog::all(); + } + + /** + * @return array + */ + public function latestEvidenceStatusByTaskType(): array + { + $tenant = Tenant::current(); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('task_type', OnboardingTaskType::all()) + ->orderByDesc('recorded_at') + ->get(); + + $byTask = []; + + foreach ($evidence as $row) { + if (! isset($byTask[$row->task_type])) { + $byTask[$row->task_type] = $row->status; + } + } + + return $byTask; + } + + public function startVerifyPermissions(): void + { + if (! $this->canStartProviderTasks) { + abort(403); + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->session instanceof OnboardingSession) { + Notification::make() + ->title('No onboarding session') + ->danger() + ->send(); + + return; + } + + if (! $this->ensureLockForMutation()) { + return; + } + + $connectionId = $this->session->provider_connection_id; + + if (! is_int($connectionId)) { + Notification::make() + ->title('Select a provider connection first') + ->warning() + ->send(); + + return; + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->whereKey($connectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('Selected provider connection not found') + ->danger() + ->send(); + + return; + } + + if ($this->session->current_step < 4) { + $this->session->update(['current_step' => 4]); + $this->session->refresh(); + } + + $taskType = OnboardingTaskType::VerifyPermissions; + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $run = $runs->ensureRunWithIdentity( + tenant: $tenant, + type: $taskType, + identityInputs: [ + 'task_type' => $taskType, + ], + context: [ + 'task_type' => $taskType, + 'onboarding_session_id' => (int) $this->session->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + initiator: $user, + ); + + $this->verifyPermissionsRunUrl = OperationRunLinks::view($run, $tenant); + + if ($run->wasRecentlyCreated) { + OnboardingVerifyPermissionsJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $this->session->getKey(), + operationRun: $run, + ); + + Notification::make() + ->title('Verify permissions queued') + ->body('Run queued. Use the link below to monitor progress.') + ->success() + ->send(); + + return; + } + + Notification::make() + ->title('Verify permissions already queued') + ->body('A run is already queued or running. Use the link below to monitor progress.') + ->warning() + ->send(); + } + + public function startConsentStatus(): void + { + if (! $this->canStartProviderTasks) { + abort(403); + } + + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403, 'Not allowed'); + } + + if (! $this->session instanceof OnboardingSession) { + return; + } + + if (! $this->ensureLockForMutation()) { + return; + } + + if ($this->session->current_step < 4) { + $this->session->update(['current_step' => 4]); + $this->session->refresh(); + } + + if (! OnboardingTaskCatalog::prerequisitesMet( + taskType: OnboardingTaskType::ConsentStatus, + latestEvidenceStatusByTaskType: $this->latestEvidenceStatusByTaskType(), + )) { + Notification::make() + ->title('Prerequisites not met') + ->body('Run “Verify permissions” first.') + ->warning() + ->send(); + + return; + } + + $connectionId = $this->session->provider_connection_id; + + if (! is_int($connectionId)) { + Notification::make() + ->title('Select a provider connection first') + ->warning() + ->send(); + + return; + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->whereKey($connectionId) + ->first(); + + if (! $connection instanceof ProviderConnection) { + Notification::make() + ->title('Selected provider connection not found') + ->danger() + ->send(); + + return; + } + + $taskType = OnboardingTaskType::ConsentStatus; + + /** @var OperationRunService $runs */ + $runs = app(OperationRunService::class); + + $run = $runs->ensureRunWithIdentity( + tenant: $tenant, + type: $taskType, + identityInputs: [ + 'task_type' => $taskType, + ], + context: [ + 'task_type' => $taskType, + 'onboarding_session_id' => (int) $this->session->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + ], + initiator: $user, + ); + + $this->consentStatusRunUrl = OperationRunLinks::view($run, $tenant); + + if ($run->wasRecentlyCreated) { + OnboardingConsentStatusJob::dispatch( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $this->session->getKey(), + operationRun: $run, + ); + + Notification::make() + ->title('Consent status queued') + ->success() + ->send(); + + return; + } + + Notification::make() + ->title('Consent status already queued') + ->warning() + ->send(); + } + + public function createProviderConnectionUrl(): string + { + return CreateProviderConnection::getUrl(tenant: Tenant::current()); + } +} diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 3b97d2c..8fd6b99 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\ProviderConnectionResource\Pages; +use App\Filament\Pages\Onboarding\TenantOnboardingWizard; use App\Filament\Resources\ProviderConnectionResource; use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderConnectionHealthCheckJob; @@ -116,6 +117,18 @@ protected function getHeaderActions(): array ->visible(false), Actions\ActionGroup::make([ + UiEnforcement::forAction( + Action::make('resume_onboarding') + ->label('Resume onboarding') + ->icon('heroicon-o-play') + ->color('gray') + ->url(fn (): string => TenantOnboardingWizard::getUrl(tenant: Tenant::current())) + ) + ->requireCapability(Capabilities::PROVIDER_VIEW) + ->tooltip('You do not have permission to view provider onboarding.') + ->preserveVisibility() + ->apply(), + UiEnforcement::forAction( Action::make('view_last_check_run') ->label('View last check run') diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 6d3bd89..dc11818 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\TenantResource\Pages; +use App\Filament\Pages\Onboarding\TenantOnboardingWizard; use App\Filament\Resources\TenantResource; use App\Models\User; use Filament\Resources\Pages\CreateRecord; @@ -10,6 +11,25 @@ class CreateTenant extends CreateRecord { protected static string $resource = TenantResource::class; + /** + * Prevent setting legacy tenant credentials during create. + * Credential setup should happen via the onboarding flow. + * + * @param array $data + * @return array + */ + protected function mutateFormDataBeforeCreate(array $data): array + { + unset( + $data['app_client_id'], + $data['app_client_secret'], + $data['app_certificate_thumbprint'], + $data['app_notes'], + ); + + return $data; + } + protected function afterCreate(): void { $user = auth()->user(); @@ -22,4 +42,9 @@ protected function afterCreate(): void $this->record->getKey() => ['role' => 'owner'], ]); } + + protected function getRedirectUrl(): string + { + return TenantOnboardingWizard::getUrl(tenant: $this->record); + } } diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index f92b500..55faff5 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\TenantResource\Pages; +use App\Filament\Pages\Onboarding\TenantOnboardingWizard; use App\Filament\Resources\TenantResource; use App\Filament\Widgets\Tenant\TenantArchivedBanner; use App\Models\Tenant; @@ -30,6 +31,17 @@ protected function getHeaderActions(): array { return [ Actions\ActionGroup::make([ + UiEnforcement::forAction( + Actions\Action::make('resume_onboarding') + ->label('Resume onboarding') + ->icon('heroicon-o-play') + ->color('gray') + ->url(fn (Tenant $record): string => TenantOnboardingWizard::getUrl(tenant: $record)) + ) + ->requireCapability(Capabilities::PROVIDER_VIEW) + ->tooltip('You do not have permission to view provider onboarding.') + ->preserveVisibility() + ->apply(), UiEnforcement::forAction( Actions\Action::make('edit') ->label('Edit') diff --git a/app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php b/app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php new file mode 100644 index 0000000..5754be9 --- /dev/null +++ b/app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php @@ -0,0 +1,142 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void + { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->onboardingSessionId); + + if (! $session instanceof OnboardingSession) { + throw new RuntimeException('OnboardingSession not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + $status = (string) ($connection->status ?? 'unknown'); + $health = (string) ($connection->health_status ?? 'unknown'); + + $evidenceStatus = 'unknown'; + $reasonCode = null; + $message = 'No health check data available yet.'; + + if ($status !== 'connected') { + $evidenceStatus = 'blocked'; + $reasonCode = 'provider.needs_consent'; + $message = 'Provider connection is not connected. Admin consent may be required.'; + } elseif ($health === 'healthy') { + $evidenceStatus = 'ok'; + $message = 'Provider connection appears healthy.'; + } elseif ($health === 'unhealthy') { + $evidenceStatus = 'error'; + $reasonCode = is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : 'provider.outage'; + $message = is_string($connection->last_error_message) && trim($connection->last_error_message) !== '' + ? $connection->last_error_message + : 'Provider connection health check indicates an error.'; + } + + $evidence->record( + tenant: $tenant, + taskType: OnboardingTaskType::ConnectionDiagnostics, + status: $evidenceStatus, + reasonCode: $reasonCode, + message: $message, + payload: [ + 'status' => $status, + 'health_status' => $health, + 'last_health_check_at' => $connection->last_health_check_at?->toIso8601String(), + 'last_error_reason_code' => $connection->last_error_reason_code, + ], + session: $session, + providerConnection: $connection, + operationRun: $this->operationRun, + recordedBy: $user, + ); + + if (! $this->operationRun instanceof OperationRun) { + return; + } + + if ($evidenceStatus === 'ok') { + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + return; + } + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'onboarding.connection.diagnostics.failed', + 'reason_code' => $reasonCode ?? 'connection.diagnostics.unknown', + 'message' => $message, + ]], + ); + } +} diff --git a/app/Jobs/Onboarding/OnboardingConsentStatusJob.php b/app/Jobs/Onboarding/OnboardingConsentStatusJob.php new file mode 100644 index 0000000..2e9eca4 --- /dev/null +++ b/app/Jobs/Onboarding/OnboardingConsentStatusJob.php @@ -0,0 +1,134 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + OnboardingEvidenceWriter $evidence, + OperationRunService $runs, + ): void { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->onboardingSessionId); + + if (! $session instanceof OnboardingSession) { + throw new RuntimeException('OnboardingSession not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + $status = (string) ($connection->status ?? 'unknown'); + + $evidenceStatus = match ($status) { + 'connected' => 'ok', + 'needs_consent' => 'blocked', + default => 'error', + }; + + $message = match ($status) { + 'connected' => 'Consent appears granted (connection is connected).', + 'needs_consent' => 'Consent is missing or credentials are not authorized yet.', + default => 'Unable to determine consent status.', + }; + + $evidence->record( + tenant: $tenant, + taskType: OnboardingTaskType::ConsentStatus, + status: $evidenceStatus, + reasonCode: $status === 'needs_consent' ? 'consent.missing' : null, + message: $message, + payload: [ + 'provider_connection_status' => $status, + 'provider_connection_health_status' => $connection->health_status, + ], + session: $session, + providerConnection: $connection, + operationRun: $this->operationRun, + recordedBy: $user, + ); + + if (! $this->operationRun instanceof OperationRun) { + return; + } + + if ($evidenceStatus === 'ok') { + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + return; + } + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'onboarding.consent.status.failed', + 'reason_code' => $status === 'needs_consent' ? 'consent.missing' : 'consent.status.error', + 'message' => $message, + ]], + ); + } +} diff --git a/app/Jobs/Onboarding/OnboardingInitialSyncJob.php b/app/Jobs/Onboarding/OnboardingInitialSyncJob.php new file mode 100644 index 0000000..d5c8b33 --- /dev/null +++ b/app/Jobs/Onboarding/OnboardingInitialSyncJob.php @@ -0,0 +1,125 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle(OnboardingEvidenceWriter $evidence, OperationRunService $runs): void + { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->onboardingSessionId); + + if (! $session instanceof OnboardingSession) { + throw new RuntimeException('OnboardingSession not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + $connected = (string) ($connection->status ?? 'unknown') === 'connected'; + + $evidenceStatus = $connected ? 'ok' : 'blocked'; + $reasonCode = $connected ? null : 'provider.not_connected'; + $message = $connected + ? 'Prerequisites for initial sync look good.' + : 'Provider connection is not connected. Resolve consent/credentials first.'; + + $evidence->record( + tenant: $tenant, + taskType: OnboardingTaskType::InitialSync, + status: $evidenceStatus, + reasonCode: $reasonCode, + message: $message, + payload: [ + 'provider_connection_status' => $connection->status, + ], + session: $session, + providerConnection: $connection, + operationRun: $this->operationRun, + recordedBy: $user, + ); + + if (! $this->operationRun instanceof OperationRun) { + return; + } + + if ($evidenceStatus === 'ok') { + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + return; + } + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'onboarding.initial_sync.blocked', + 'reason_code' => $reasonCode ?? 'initial_sync.unknown', + 'message' => $message, + ]], + ); + } +} diff --git a/app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php b/app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php new file mode 100644 index 0000000..07907e5 --- /dev/null +++ b/app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php @@ -0,0 +1,140 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + TenantPermissionService $permissions, + OnboardingEvidenceWriter $evidence, + OperationRunService $runs, + ): void { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->onboardingSessionId); + + if (! $session instanceof OnboardingSession) { + throw new RuntimeException('OnboardingSession not found.'); + } + + $connection = ProviderConnection::query() + ->where('tenant_id', $tenant->getKey()) + ->find($this->providerConnectionId); + + if (! $connection instanceof ProviderConnection) { + throw new RuntimeException('ProviderConnection not found.'); + } + + // For onboarding, we default to a safe, non-live permission comparison. + // Live Graph calls can be enabled later as a deliberate UX and contract decision. + $result = $permissions->compare($tenant, persist: true, liveCheck: false, useConfiguredStub: true); + + $overall = $result['overall_status'] ?? 'error'; + + $evidenceStatus = match ($overall) { + 'granted' => 'ok', + 'missing' => 'blocked', + default => 'error', + }; + + $message = match ($overall) { + 'granted' => 'All required permissions appear granted.', + 'missing' => 'Some required permissions are missing.', + default => 'Unable to verify permissions.', + }; + + $evidence->record( + tenant: $tenant, + taskType: OnboardingTaskType::VerifyPermissions, + status: $evidenceStatus, + reasonCode: $overall === 'missing' ? 'permissions.missing' : null, + message: $message, + payload: [ + 'overall_status' => $overall, + 'permissions' => $result['permissions'] ?? [], + ], + session: $session, + providerConnection: $connection, + operationRun: $this->operationRun, + recordedBy: $user, + ); + + if (! $this->operationRun instanceof OperationRun) { + return; + } + + if ($evidenceStatus === 'ok') { + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + return; + } + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'onboarding.permissions.verify.failed', + 'reason_code' => $overall === 'missing' ? 'permissions.missing' : 'permissions.verify.error', + 'message' => $message, + ]], + ); + } +} diff --git a/app/Models/OnboardingEvidence.php b/app/Models/OnboardingEvidence.php new file mode 100644 index 0000000..eecac91 --- /dev/null +++ b/app/Models/OnboardingEvidence.php @@ -0,0 +1,46 @@ + 'array', + 'recorded_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function onboardingSession(): BelongsTo + { + return $this->belongsTo(OnboardingSession::class, 'onboarding_session_id'); + } + + public function providerConnection(): BelongsTo + { + return $this->belongsTo(ProviderConnection::class, 'provider_connection_id'); + } + + public function operationRun(): BelongsTo + { + return $this->belongsTo(OperationRun::class, 'operation_run_id'); + } + + public function recordedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'recorded_by_user_id'); + } +} diff --git a/app/Models/OnboardingSession.php b/app/Models/OnboardingSession.php new file mode 100644 index 0000000..601454e --- /dev/null +++ b/app/Models/OnboardingSession.php @@ -0,0 +1,47 @@ + 'integer', + 'locked_until' => 'datetime', + 'completed_at' => 'datetime', + 'metadata' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function providerConnection(): BelongsTo + { + return $this->belongsTo(ProviderConnection::class, 'provider_connection_id'); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to_user_id'); + } + + public function lockedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'locked_by_user_id'); + } + + public function evidence(): HasMany + { + return $this->hasMany(OnboardingEvidence::class, 'onboarding_session_id'); + } +} diff --git a/app/Policies/OnboardingEvidencePolicy.php b/app/Policies/OnboardingEvidencePolicy.php new file mode 100644 index 0000000..1d2cb71 --- /dev/null +++ b/app/Policies/OnboardingEvidencePolicy.php @@ -0,0 +1,58 @@ +isMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW) + ? true + : Response::deny(); + } + + public function view(User $user, OnboardingEvidence $evidence): Response|bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if ((int) $evidence->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW) + ? true + : Response::deny(); + } +} diff --git a/app/Policies/OnboardingSessionPolicy.php b/app/Policies/OnboardingSessionPolicy.php new file mode 100644 index 0000000..13fd400 --- /dev/null +++ b/app/Policies/OnboardingSessionPolicy.php @@ -0,0 +1,100 @@ +isMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW) + ? true + : Response::deny(); + } + + public function view(User $user, OnboardingSession $session): Response|bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if ((int) $session->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW) + ? true + : Response::deny(); + } + + public function create(User $user): Response|bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE) + ? true + : Response::deny(); + } + + public function update(User $user, OnboardingSession $session): Response|bool + { + $tenant = Tenant::current(); + + if (! $tenant) { + return false; + } + + if ((int) $session->tenant_id !== (int) $tenant->getKey()) { + return Response::denyAsNotFound(); + } + + $resolver = app(CapabilityResolver::class); + + if (! $resolver->isMember($user, $tenant)) { + return Response::denyAsNotFound(); + } + + return $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE) + ? true + : Response::deny(); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index d52e8f8..f93ea02 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -2,10 +2,14 @@ namespace App\Providers; +use App\Models\OnboardingEvidence; +use App\Models\OnboardingSession; use App\Models\PlatformUser; use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\User; +use App\Policies\OnboardingEvidencePolicy; +use App\Policies\OnboardingSessionPolicy; use App\Policies\ProviderConnectionPolicy; use App\Services\Auth\CapabilityResolver; use App\Support\Auth\Capabilities; @@ -17,6 +21,8 @@ class AuthServiceProvider extends ServiceProvider { protected $policies = [ ProviderConnection::class => ProviderConnectionPolicy::class, + OnboardingSession::class => OnboardingSessionPolicy::class, + OnboardingEvidence::class => OnboardingEvidencePolicy::class, ]; public function boot(): void diff --git a/app/Services/Graph/ScopeTagResolver.php b/app/Services/Graph/ScopeTagResolver.php index 0460eeb..fe47d3d 100644 --- a/app/Services/Graph/ScopeTagResolver.php +++ b/app/Services/Graph/ScopeTagResolver.php @@ -43,18 +43,18 @@ public function resolve(array $scopeTagIds, ?Tenant $tenant = null): array private function fetchAllScopeTags(?Tenant $tenant = null): array { $cacheKey = $tenant ? "scope_tags:tenant:{$tenant->id}" : 'scope_tags:all'; - + return Cache::remember($cacheKey, 3600, function () use ($tenant) { try { $options = ['query' => ['$select' => 'id,displayName']]; - + // Add tenant credentials if provided if ($tenant) { $options['tenant'] = $tenant->external_id ?? $tenant->tenant_id; $options['client_id'] = $tenant->app_client_id; $options['client_secret'] = $tenant->app_client_secret; } - + $graphResponse = $this->graphClient->request( 'GET', '/deviceManagement/roleScopeTags', diff --git a/app/Services/Onboarding/LegacyTenantCredentialMigrator.php b/app/Services/Onboarding/LegacyTenantCredentialMigrator.php new file mode 100644 index 0000000..6ef42e2 --- /dev/null +++ b/app/Services/Onboarding/LegacyTenantCredentialMigrator.php @@ -0,0 +1,68 @@ +tenant_id !== (int) $tenant->getKey()) { + throw new InvalidArgumentException('Provider connection does not belong to the tenant.'); + } + + $clientId = trim((string) ($tenant->app_client_id ?? '')); + $clientSecret = trim((string) ($tenant->app_client_secret ?? '')); + + if ($clientId === '' || $clientSecret === '') { + return [ + 'migrated' => false, + 'message' => 'No legacy tenant credentials found to migrate.', + ]; + } + + $existing = $connection->credential; + + if ($existing instanceof ProviderCredential) { + if ($existing->type !== 'client_secret') { + throw new RuntimeException('Provider connection has unsupported credential type.'); + } + + $payload = $existing->payload; + $existingClientId = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_id')); + $existingClientSecret = trim((string) Arr::get(is_array($payload) ? $payload : [], 'client_secret')); + + if ($existingClientId !== '' && $existingClientSecret !== '') { + return [ + 'migrated' => false, + 'message' => 'Provider credentials already exist for this connection.', + ]; + } + } + + $this->credentials->upsertClientSecretCredential( + connection: $connection, + clientId: $clientId, + clientSecret: $clientSecret, + ); + + return [ + 'migrated' => true, + 'message' => 'Legacy tenant credentials migrated to the provider connection.', + ]; + } +} diff --git a/app/Services/Onboarding/OnboardingEvidenceWriter.php b/app/Services/Onboarding/OnboardingEvidenceWriter.php new file mode 100644 index 0000000..763dde8 --- /dev/null +++ b/app/Services/Onboarding/OnboardingEvidenceWriter.php @@ -0,0 +1,94 @@ + $payload + */ + public function record( + Tenant $tenant, + string $taskType, + string $status, + ?string $reasonCode = null, + ?string $message = null, + array $payload = [], + ?OnboardingSession $session = null, + ?ProviderConnection $providerConnection = null, + ?OperationRun $operationRun = null, + ?User $recordedBy = null, + ): OnboardingEvidence { + $reasonCode = $reasonCode === null ? null : RunFailureSanitizer::normalizeReasonCode($reasonCode); + $message = $message === null ? null : RunFailureSanitizer::sanitizeMessage($message); + + /** @var array $payload */ + $payload = $this->sanitizePayload($payload); + + return OnboardingEvidence::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'onboarding_session_id' => $session?->getKey(), + 'provider_connection_id' => $providerConnection?->getKey(), + 'task_type' => $taskType, + 'status' => $status, + 'reason_code' => $reasonCode, + 'message' => $message, + 'payload' => $payload, + 'operation_run_id' => $operationRun?->getKey(), + 'recorded_at' => now(), + 'recorded_by_user_id' => $recordedBy?->getKey(), + ]); + } + + /** + * @param array $payload + * @return array + */ + private function sanitizePayload(array $payload): array + { + $redactedKeys = ['access_token', 'refresh_token', 'client_secret', 'password', 'authorization', 'bearer']; + + $sanitize = function (mixed $value) use (&$sanitize, $redactedKeys): mixed { + if (is_array($value)) { + $out = []; + + foreach ($value as $k => $v) { + $key = is_string($k) ? strtolower($k) : null; + + if ($key !== null) { + foreach ($redactedKeys as $needle) { + if (str_contains($key, $needle)) { + $out[$k] = '[REDACTED]'; + + continue 2; + } + } + } + + $out[$k] = $sanitize($v); + } + + return $out; + } + + if (is_string($value)) { + return RunFailureSanitizer::sanitizeMessage($value); + } + + return $value; + }; + + /** @var array $sanitized */ + $sanitized = $sanitize($payload); + + return $sanitized; + } +} diff --git a/app/Services/Onboarding/OnboardingLockService.php b/app/Services/Onboarding/OnboardingLockService.php new file mode 100644 index 0000000..92c481e --- /dev/null +++ b/app/Services/Onboarding/OnboardingLockService.php @@ -0,0 +1,89 @@ +lockForUpdate()->findOrFail($session->getKey()); + + if ($this->isLockedByOther($session, $user)) { + return false; + } + + $session->forceFill([ + 'locked_by_user_id' => $user->getKey(), + 'locked_until' => Carbon::now()->addSeconds($ttlSeconds), + ])->save(); + + return true; + }); + } + + public function renew(OnboardingSession $session, User $user, int $ttlSeconds = 600): bool + { + return DB::transaction(function () use ($session, $user, $ttlSeconds): bool { + $session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey()); + + if ((int) $session->locked_by_user_id !== (int) $user->getKey()) { + return false; + } + + $session->forceFill([ + 'locked_until' => Carbon::now()->addSeconds($ttlSeconds), + ])->save(); + + return true; + }); + } + + public function release(OnboardingSession $session, User $user): bool + { + return DB::transaction(function () use ($session, $user): bool { + $session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey()); + + if ((int) $session->locked_by_user_id !== (int) $user->getKey()) { + return false; + } + + $session->forceFill([ + 'locked_by_user_id' => null, + 'locked_until' => null, + ])->save(); + + return true; + }); + } + + public function takeover(OnboardingSession $session, User $newOwner, int $ttlSeconds = 600): void + { + DB::transaction(function () use ($session, $newOwner, $ttlSeconds): void { + $session = OnboardingSession::query()->lockForUpdate()->findOrFail($session->getKey()); + + $session->forceFill([ + 'locked_by_user_id' => $newOwner->getKey(), + 'locked_until' => Carbon::now()->addSeconds($ttlSeconds), + ])->save(); + }); + } + + private function isLockedByOther(OnboardingSession $session, User $user): bool + { + if ($session->locked_by_user_id === null || $session->locked_until === null) { + return false; + } + + if ($session->locked_until->isPast()) { + return false; + } + + return (int) $session->locked_by_user_id !== (int) $user->getKey(); + } +} diff --git a/app/Support/Auth/UiEnforcement.php b/app/Support/Auth/UiEnforcement.php index 9686556..b48fa8b 100644 --- a/app/Support/Auth/UiEnforcement.php +++ b/app/Support/Auth/UiEnforcement.php @@ -44,9 +44,7 @@ class UiEnforcement */ private ?\Closure $bulkPreflight = null; - public function __construct(private string $capability) - { - } + public function __construct(private string $capability) {} public static function for(string $capability): self { @@ -418,6 +416,7 @@ private function resolveTenantIdsForRecords(Collection $records): array if ($resolved instanceof Tenant) { $ids[] = (int) $resolved->getKey(); + continue; } diff --git a/app/Support/Auth/UiTooltips.php b/app/Support/Auth/UiTooltips.php index 05dd60a..e5312cb 100644 --- a/app/Support/Auth/UiTooltips.php +++ b/app/Support/Auth/UiTooltips.php @@ -11,4 +11,3 @@ public static function insufficientPermission(): string return self::INSUFFICIENT_PERMISSION_ASK_OWNER; } } - diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index 0432584..087d49c 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -34,6 +34,7 @@ final class BadgeCatalog BadgeDomain::IgnoredAt->value => Domains\IgnoredAtBadge::class, BadgeDomain::RestorePreviewDecision->value => Domains\RestorePreviewDecisionBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, + BadgeDomain::OnboardingTaskStatus->value => Domains\OnboardingTaskStatusBadge::class, BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, ]; diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index 8e0710a..143cd76 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -26,6 +26,7 @@ enum BadgeDomain: string case IgnoredAt = 'ignored_at'; case RestorePreviewDecision = 'restore_preview_decision'; case RestoreResultStatus = 'restore_result_status'; + case OnboardingTaskStatus = 'onboarding_task.status'; case ProviderConnectionStatus = 'provider_connection.status'; case ProviderConnectionHealth = 'provider_connection.health'; } diff --git a/app/Support/Badges/Domains/OnboardingTaskStatusBadge.php b/app/Support/Badges/Domains/OnboardingTaskStatusBadge.php new file mode 100644 index 0000000..0b93cc1 --- /dev/null +++ b/app/Support/Badges/Domains/OnboardingTaskStatusBadge.php @@ -0,0 +1,23 @@ + new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'), + 'warn' => new BadgeSpec('Warning', 'warning', 'heroicon-m-exclamation-triangle'), + 'fail' => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), + 'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Onboarding/OnboardingFixHints.php b/app/Support/Onboarding/OnboardingFixHints.php new file mode 100644 index 0000000..07099d7 --- /dev/null +++ b/app/Support/Onboarding/OnboardingFixHints.php @@ -0,0 +1,54 @@ + + */ + public static function forReason(?string $reasonCode): array + { + if (! is_string($reasonCode) || trim($reasonCode) === '') { + return []; + } + + $normalized = RunFailureSanitizer::normalizeReasonCode($reasonCode); + + return match ($normalized) { + RunFailureSanitizer::REASON_PERMISSION_DENIED => [ + 'Confirm admin consent is granted for all required Microsoft Graph permissions.', + 'Verify the Azure app registration has the correct API permissions assigned.', + 'Re-run “Verify permissions” after updating consent/permissions.', + ], + RunFailureSanitizer::REASON_PROVIDER_AUTH_FAILED => [ + 'Confirm the client secret is valid and not expired.', + 'Verify the tenant ID and client ID are correct for this connection.', + 'Re-save credentials and re-run the task.', + ], + RunFailureSanitizer::REASON_GRAPH_THROTTLED, RunFailureSanitizer::REASON_GRAPH_TIMEOUT => [ + 'Wait a few minutes and try again (transient Graph errors are common).', + 'Run the task during off-peak hours if throttling persists.', + ], + RunFailureSanitizer::REASON_PROVIDER_OUTAGE => [ + 'Check Microsoft 365 service health / Graph status and try again later.', + ], + RunFailureSanitizer::REASON_VALIDATION_ERROR => [ + 'Double-check the selected provider connection and tenant settings.', + 'Review the error message for which input is invalid (no secrets are shown).', + ], + RunFailureSanitizer::REASON_CONFLICT_DETECTED => [ + 'Review what changed in the tenant before rerunning.', + 'If a conflicting configuration exists, resolve it and re-run the task.', + ], + default => [ + 'Retry the task and review the latest evidence message.', + 'If the issue persists, ask an Owner to review tenant access and connection settings.', + ], + }; + } +} diff --git a/app/Support/Onboarding/OnboardingTaskCatalog.php b/app/Support/Onboarding/OnboardingTaskCatalog.php new file mode 100644 index 0000000..83322bf --- /dev/null +++ b/app/Support/Onboarding/OnboardingTaskCatalog.php @@ -0,0 +1,86 @@ +}> + */ + public static function all(): array + { + return [ + [ + 'task_type' => OnboardingTaskType::VerifyPermissions, + 'title' => 'Verify permissions', + 'step' => 4, + 'prerequisites' => [], + ], + [ + 'task_type' => OnboardingTaskType::ConsentStatus, + 'title' => 'Check consent status', + 'step' => 4, + 'prerequisites' => [OnboardingTaskType::VerifyPermissions], + ], + [ + 'task_type' => OnboardingTaskType::ConnectionDiagnostics, + 'title' => 'Run connection diagnostics', + 'step' => 4, + 'prerequisites' => [OnboardingTaskType::VerifyPermissions], + ], + [ + 'task_type' => OnboardingTaskType::InitialSync, + 'title' => 'Initial sync', + 'step' => 5, + 'prerequisites' => [OnboardingTaskType::VerifyPermissions], + ], + ]; + } + + /** + * @return array{task_type: string, title: string, step: int, prerequisites: array}|null + */ + public static function find(string $taskType): ?array + { + foreach (self::all() as $task) { + if ($task['task_type'] === $taskType) { + return $task; + } + } + + return null; + } + + /** + * @param array $latestEvidenceStatusByTaskType + */ + public static function prerequisitesMet(string $taskType, array $latestEvidenceStatusByTaskType): bool + { + return count(self::unmetPrerequisites($taskType, $latestEvidenceStatusByTaskType)) === 0; + } + + /** + * @param array $latestEvidenceStatusByTaskType + * @return array + */ + public static function unmetPrerequisites(string $taskType, array $latestEvidenceStatusByTaskType): array + { + $task = self::find($taskType); + + if (! $task) { + return []; + } + + $unmet = []; + + foreach ($task['prerequisites'] as $requiredTaskType) { + $status = $latestEvidenceStatusByTaskType[$requiredTaskType] ?? 'unknown'; + + if ($status !== 'ok') { + $unmet[] = $requiredTaskType; + } + } + + return $unmet; + } +} diff --git a/app/Support/Onboarding/OnboardingTaskType.php b/app/Support/Onboarding/OnboardingTaskType.php new file mode 100644 index 0000000..0b248a0 --- /dev/null +++ b/app/Support/Onboarding/OnboardingTaskType.php @@ -0,0 +1,32 @@ + + */ + public static function all(): array + { + return [ + self::VerifyPermissions, + self::ConsentStatus, + self::ConnectionDiagnostics, + self::InitialSync, + ]; + } + + public static function isKnown(string $taskType): bool + { + return in_array($taskType, self::all(), true); + } +} diff --git a/database/factories/OnboardingEvidenceFactory.php b/database/factories/OnboardingEvidenceFactory.php new file mode 100644 index 0000000..b70cf4a --- /dev/null +++ b/database/factories/OnboardingEvidenceFactory.php @@ -0,0 +1,32 @@ + + */ +class OnboardingEvidenceFactory extends Factory +{ + protected $model = OnboardingEvidence::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'onboarding_session_id' => null, + 'provider_connection_id' => null, + 'task_type' => 'onboarding.unknown', + 'status' => 'unknown', + 'reason_code' => null, + 'message' => null, + 'payload' => [], + 'operation_run_id' => null, + 'recorded_at' => now(), + 'recorded_by_user_id' => null, + ]; + } +} diff --git a/database/factories/OnboardingSessionFactory.php b/database/factories/OnboardingSessionFactory.php new file mode 100644 index 0000000..37d981c --- /dev/null +++ b/database/factories/OnboardingSessionFactory.php @@ -0,0 +1,30 @@ + + */ +class OnboardingSessionFactory extends Factory +{ + protected $model = OnboardingSession::class; + + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'provider_connection_id' => null, + 'status' => 'draft', + 'current_step' => 1, + 'assigned_to_user_id' => null, + 'locked_by_user_id' => null, + 'locked_until' => null, + 'completed_at' => null, + 'metadata' => [], + ]; + } +} diff --git a/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php b/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php index efc7a04..8296088 100644 --- a/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php +++ b/database/migrations/2025_12_22_171525_add_assignments_to_policy_versions.php @@ -16,7 +16,7 @@ public function up(): void $table->json('scope_tags')->nullable()->after('assignments'); $table->string('assignments_hash', 64)->nullable()->after('scope_tags'); $table->string('scope_tags_hash', 64)->nullable()->after('assignments_hash'); - + $table->index('assignments_hash'); $table->index('scope_tags_hash'); }); diff --git a/database/migrations/2026_02_01_002427_create_onboarding_sessions_table.php b/database/migrations/2026_02_01_002427_create_onboarding_sessions_table.php new file mode 100644 index 0000000..84f7443 --- /dev/null +++ b/database/migrations/2026_02_01_002427_create_onboarding_sessions_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete(); + + $table->string('status')->default('draft'); + $table->unsignedSmallInteger('current_step')->default(1); + + $table->foreignId('assigned_to_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('locked_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('locked_until')->nullable(); + + $table->timestamp('completed_at')->nullable(); + $table->jsonb('metadata')->default('{}'); + $table->timestamps(); + + $table->index(['tenant_id', 'status', 'created_at']); + $table->index(['tenant_id', 'provider_connection_id', 'created_at']); + }); + + // At most one active session per tenant. + DB::statement("CREATE UNIQUE INDEX onboarding_sessions_active_unique ON onboarding_sessions (tenant_id) WHERE status IN ('draft', 'in_progress')"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS onboarding_sessions_active_unique'); + Schema::dropIfExists('onboarding_sessions'); + } +}; diff --git a/database/migrations/2026_02_01_002428_create_onboarding_evidence_table.php b/database/migrations/2026_02_01_002428_create_onboarding_evidence_table.php new file mode 100644 index 0000000..919c224 --- /dev/null +++ b/database/migrations/2026_02_01_002428_create_onboarding_evidence_table.php @@ -0,0 +1,44 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('onboarding_session_id')->nullable()->constrained('onboarding_sessions')->nullOnDelete(); + $table->foreignId('provider_connection_id')->nullable()->constrained('provider_connections')->nullOnDelete(); + + $table->string('task_type'); + $table->string('status')->default('unknown'); + $table->string('reason_code')->nullable(); + $table->string('message')->nullable(); + $table->jsonb('payload')->default('{}'); + + $table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete(); + $table->timestamp('recorded_at'); + $table->foreignId('recorded_by_user_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + + $table->index(['tenant_id', 'task_type', 'recorded_at']); + $table->index(['tenant_id', 'task_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('onboarding_evidence'); + } +}; diff --git a/phpunit.xml b/phpunit.xml index 75c4ea3..16c4af7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -20,6 +20,7 @@ + diff --git a/resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php b/resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php new file mode 100644 index 0000000..5e8c600 --- /dev/null +++ b/resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php @@ -0,0 +1,169 @@ + +
+
+
+
+

Onboarding task board

+

+ Run and re-run onboarding tasks. Status comes from stored evidence. +

+
+ + @if ($session) +
+ Step {{ $session->current_step }} +
+ @endif +
+
+ +
+ @foreach ($this->taskRows() as $row) + @php + $taskType = $row['task_type']; + $badge = $row['badge']; + $evidence = $row['evidence']; + $unmet = $row['unmet_prerequisites']; + $disabled = ! $canStartProviderTasks || ! $row['prerequisites_met']; + $hints = $this->fixHintsFor($evidence?->reason_code); + @endphp + +
+
+
+
+ {{ $row['title'] }} +
+
+ {{ $taskType }} · Step {{ $row['step'] }} +
+ + @if (count($unmet) > 0) +
+ Blocked by prerequisites: {{ implode(', ', $unmet) }} +
+ @endif +
+ +
+ + {{ $badge->label }} + + + + + @if (isset($runUrls[$taskType]) && is_string($runUrls[$taskType]) && $runUrls[$taskType] !== '') + + View run + + @endif +
+
+ + @if ($evidence) +
+
+
Latest evidence
+ + @if (is_string($evidence->reason_code) && $evidence->reason_code !== '') +
+ Reason: {{ $evidence->reason_code }} +
+ @endif + + @if (is_string($evidence->message) && $evidence->message !== '') +
{{ $evidence->message }}
+ @endif +
+ + @if (count($hints) > 0) +
+
Fix hints
+
    + @foreach ($hints as $hint) +
  • {{ $hint }}
  • + @endforeach +
+
+ @endif +
+ @endif +
+ @endforeach +
+ +
+
+

Evidence history

+

+ Recent onboarding evidence entries across all tasks. +

+
+ + @php + $recent = $this->recentEvidenceRows(); + @endphp + + @if (count($recent) === 0) +
No evidence recorded yet.
+ @else +
+ + + + + + + + + + + + @foreach ($recent as $row) + + + + + + + + @endforeach + +
RecordedTaskStatusReasonMessage
+ {{ $row['recorded_at'] }} + + {{ $row['task_type'] }} + @if (is_string($row['run_url']) && $row['run_url'] !== '') + + @endif + + + {{ $row['badge']->label }} + + + {{ $row['reason_code'] ?? '' }} + + {{ $row['message'] ?? '' }} +
+
+ @endif +
+
+
diff --git a/resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php b/resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php new file mode 100644 index 0000000..1f29715 --- /dev/null +++ b/resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php @@ -0,0 +1,211 @@ + +
+ @if ($sessionLockedByOther) +
+
Session locked
+
+ Onboarding is currently locked by {{ $sessionLockedByLabel ?? 'another user' }} + @if (is_string($sessionLockedUntil) && $sessionLockedUntil !== '') + (expires {{ $sessionLockedUntil }}). + @endif + You can view progress, but you can’t make changes unless you take over the lock. +
+
+ @elseif ($hasSessionLock) +
+
You have the lock
+ @if (is_string($sessionLockedUntil) && $sessionLockedUntil !== '') +
Expires {{ $sessionLockedUntil }}.
+ @endif +
+ @endif + +
+
+

Onboarding plan

+

+ This plan is shown before running any tasks. +

+
+ +
+
    + @foreach ($this->planTasks() as $task) +
  • +
    +
    + {{ $task['title'] }} +
    +
    + Step {{ $task['step'] }} +
    +
    +
    + {{ $task['task_type'] }} +
    +
  • + @endforeach +
+
+
+ + @if (! $canStartProviderTasks) +
+
Missing permission
+
+ You can view onboarding, but running provider tasks requires additional permission. +
+
+ @endif + + @if ($session?->current_step !== null && $session->current_step >= 4) +
+

Consent

+

+ Ensure admin consent is granted for the required Microsoft Graph permissions before running tasks. +

+ +
+ +
+ +
+

+ This wizard never shows secrets. Credentials remain managed by the provider credential store. +

+
+ + +
+ + @php + $statuses = $this->latestEvidenceStatusByTaskType(); + $verifyStatus = $statuses[\App\Support\Onboarding\OnboardingTaskType::VerifyPermissions] ?? 'unknown'; + $verifySpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OnboardingTaskStatus, $verifyStatus); + $consentStatus = $statuses[\App\Support\Onboarding\OnboardingTaskType::ConsentStatus] ?? 'unknown'; + $consentSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::OnboardingTaskStatus, $consentStatus); + @endphp + +
+
+
+

Step 4 tasks

+

Run verification tasks and review evidence-driven status.

+ + +
+ +
+ + Verify permissions: {{ $verifySpec->label }} + + + + Consent status: {{ $consentSpec->label }} + +
+
+ +
+ + + +
+ + @if (is_string($verifyPermissionsRunUrl) && $verifyPermissionsRunUrl !== '') + + @endif + + @if (is_string($consentStatusRunUrl) && $consentStatusRunUrl !== '') + + @endif +
+ @else +
+

Provider connection

+

+ Create or select a provider connection before starting tasks. +

+ +
+ +
+ +
+

+ This wizard never shows secrets. Credentials remain managed by the provider credential store. +

+
+ + +
+ @endif +
+
diff --git a/specs/069-tenant-onboarding-wizard-v2/tasks.md b/specs/069-tenant-onboarding-wizard-v2/tasks.md index ad21a78..6e46085 100644 --- a/specs/069-tenant-onboarding-wizard-v2/tasks.md +++ b/specs/069-tenant-onboarding-wizard-v2/tasks.md @@ -25,34 +25,38 @@ # Tasks: Managed Tenant Onboarding Wizard UI (v2) (069) ## Phase 1: Setup (Shared Infrastructure) -- [ ] T001 Create onboarding feature folders `app/Filament/Pages/Onboarding/`, `resources/views/filament/pages/onboarding/`, `tests/Feature/Onboarding/`, `tests/Unit/Onboarding/` -- [ ] T002 [P] Add a focused Pest test file scaffold for onboarding in `tests/Feature/Onboarding/OnboardingSmokeTest.php` +- [X] T001 Create onboarding feature folders `app/Filament/Pages/Onboarding/`, `resources/views/filament/pages/onboarding/`, `tests/Feature/Onboarding/`, `tests/Unit/Onboarding/` +- [X] T002 [P] Add a focused Pest test file scaffold for onboarding in `tests/Feature/Onboarding/OnboardingSmokeTest.php` --- ## Phase 2: Foundational (Blocking Prerequisites) -- [ ] T003 Create onboarding sessions migration in `database/migrations/` (new `onboarding_sessions` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`) -- [ ] T004 Create onboarding evidence migration in `database/migrations/` (new `onboarding_evidence` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`) -- [ ] T005 [P] Create `OnboardingSession` model in `app/Models/OnboardingSession.php` -- [ ] T006 [P] Create `OnboardingEvidence` model in `app/Models/OnboardingEvidence.php` -- [ ] T007 [P] Add factories for onboarding models in `database/factories/OnboardingSessionFactory.php` and `database/factories/OnboardingEvidenceFactory.php` -- [ ] T008 [P] Add onboarding session policy in `app/Policies/OnboardingSessionPolicy.php` (404 vs 403 semantics, capability-based) -- [ ] T009 [P] Add onboarding evidence policy in `app/Policies/OnboardingEvidencePolicy.php` (view-only access, capability-based) -- [ ] T010 Register new policies in `app/Providers/AuthServiceProvider.php` +- [X] T003 Create onboarding sessions migration in `database/migrations/` (new `onboarding_sessions` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`) +- [X] T004 Create onboarding evidence migration in `database/migrations/` (new `onboarding_evidence` table per `specs/069-tenant-onboarding-wizard-v2/data-model.md`) -- [ ] T011 [P] Create task-type enum/keys in `app/Support/Onboarding/OnboardingTaskType.php` (stable `task_type` strings) -- [ ] T012 [P] Create task catalog in `app/Support/Onboarding/OnboardingTaskCatalog.php` (prereqs, evidence types, operation run type/job mapping) -- [ ] T013 [P] Create evidence writer service in `app/Services/Onboarding/OnboardingEvidenceWriter.php` (sanitization via `App\\Support\\OpsUx\\RunFailureSanitizer`) -- [ ] T014 [P] Create onboarding lock service in `app/Services/Onboarding/OnboardingLockService.php` (lock acquire/renew/release + takeover) +- [X] T005 [P] Create `OnboardingSession` model in `app/Models/OnboardingSession.php` +- [X] T006 [P] Create `OnboardingEvidence` model in `app/Models/OnboardingEvidence.php` +- [X] T007 [P] Add factories for onboarding models in `database/factories/OnboardingSessionFactory.php` and `database/factories/OnboardingEvidenceFactory.php` +- [X] T008 [P] Add onboarding session policy in `app/Policies/OnboardingSessionPolicy.php` (404 vs 403 semantics, capability-based) +- [X] T009 [P] Add onboarding evidence policy in `app/Policies/OnboardingEvidencePolicy.php` (view-only access, capability-based) +- [X] T010 Register new policies in `app/Providers/AuthServiceProvider.php` -- [ ] T015 [P] Add badge domain for onboarding task status in `app/Support/Badges/BadgeDomain.php` -- [ ] T016 [P] Add badge mapper for onboarding task status in `app/Support/Badges/Domains/OnboardingTaskStatusBadge.php` -- [ ] T017 Update badge catalog mapping in `app/Support/Badges/BadgeCatalog.php` for the new onboarding domain -- [ ] T018 [P] Add badge mapping unit tests in `tests/Unit/Badges/OnboardingBadgesTest.php` -- [ ] T019 [P] Add onboarding service tests for evidence sanitization in `tests/Unit/Onboarding/OnboardingEvidenceWriterTest.php` -- [ ] T020 [P] Add onboarding lock behavior unit tests in `tests/Unit/Onboarding/OnboardingLockServiceTest.php` +- [X] T011 [P] Create task-type enum/keys in `app/Support/Onboarding/OnboardingTaskType.php` (stable `task_type` strings) +- [X] T012 [P] Create task catalog in `app/Support/Onboarding/OnboardingTaskCatalog.php` (prereqs, evidence types, operation run type/job mapping) +- [X] T013 [P] Create evidence writer service in `app/Services/Onboarding/OnboardingEvidenceWriter.php` (sanitization via `App\\Support\\OpsUx\\RunFailureSanitizer`) +- [X] T014 [P] Create onboarding lock service in `app/Services/Onboarding/OnboardingLockService.php` (lock acquire/renew/release + takeover) + + +- [X] T015 [P] Add badge domain for onboarding task status in `app/Support/Badges/BadgeDomain.php` +- [X] T016 [P] Add badge mapper for onboarding task status in `app/Support/Badges/Domains/OnboardingTaskStatusBadge.php` +- [X] T017 Update badge catalog mapping in `app/Support/Badges/BadgeCatalog.php` for the new onboarding domain +- [X] T018 [P] Add badge mapping unit tests in `tests/Unit/Badges/OnboardingBadgesTest.php` + + +- [X] T019 [P] Add onboarding service tests for evidence sanitization in `tests/Unit/Onboarding/OnboardingEvidenceWriterTest.php` +- [X] T020 [P] Add onboarding lock behavior unit tests in `tests/Unit/Onboarding/OnboardingLockServiceTest.php` **Checkpoint**: DB schema, models, policies, badge semantics, and core services exist. @@ -66,38 +70,41 @@ ## Phase 3: User Story 1 — Onboard a managed tenant with a provider connection ### Tests (write first) -- [ ] T021 [P] [US1] Feature test: Owner can create/resume onboarding session in `tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php` -- [ ] T022 [P] [US1] Feature test: non-member is denied-as-not-found (404) in `tests/Feature/Onboarding/OnboardingAuthorizationTest.php` -- [ ] T023 [P] [US1] Feature test: readonly can view but cannot mutate in `tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php` -- [ ] T059 [P] [US1] Feature test: onboarding plan preview is shown before any task execution in `tests/Feature/Onboarding/OnboardingPlanPreviewTest.php` -- [ ] T060 [P] [US1] Feature test: duplicate onboarding/session handling navigates to resume/task board safely in `tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php` -- [ ] T061 [P] [US1] Feature test: consent guidance is visible in Step 4 and is safe/sanitized in `tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php` -- [ ] T062 [P] [US1] Feature test: role-aware guidance (capability required messaging) renders for tenant members in `tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php` -- [ ] T063 [P] [US1] Feature test: user can create a provider connection from onboarding flow (navigate + return) in `tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php` +- [X] T021 [P] [US1] Feature test: Owner can create/resume onboarding session in `tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php` +- [X] T022 [P] [US1] Feature test: non-member is denied-as-not-found (404) in `tests/Feature/Onboarding/OnboardingAuthorizationTest.php` +- [X] T023 [P] [US1] Feature test: readonly can view but cannot mutate in `tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php` +- [X] T059 [P] [US1] Feature test: onboarding plan preview is shown before any task execution in `tests/Feature/Onboarding/OnboardingPlanPreviewTest.php` +- [X] T060 [P] [US1] Feature test: duplicate onboarding/session handling navigates to resume/task board safely in `tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php` +- [X] T061 [P] [US1] Feature test: consent guidance is visible in Step 4 and is safe/sanitized in `tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php` +- [X] T062 [P] [US1] Feature test: role-aware guidance (capability required messaging) renders for tenant members in `tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php` +- [X] T063 [P] [US1] Feature test: user can create a provider connection from onboarding flow (navigate + return) in `tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php` ### Implementation -- [ ] T024 [US1] Add onboarding wizard page in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (5 steps, evidence-driven status) -- [ ] T025 [US1] Add wizard Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php` +- [X] T024 [US1] Add onboarding wizard page in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (5 steps, evidence-driven status) +- [X] T025 [US1] Add wizard Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-wizard.blade.php` -- [ ] T064 [US1] Implement onboarding plan preview in early steps (Step 1/2) using `OnboardingTaskCatalog` (tasks + prerequisites) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` -- [ ] T065 [US1] Implement duplicate onboarding/session handling: always resume active session; block conflicting session creation in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` +- [X] T064 [US1] Implement onboarding plan preview in early steps (Step 1/2) using `OnboardingTaskCatalog` (tasks + prerequisites) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` +- [X] T065 [US1] Implement duplicate onboarding/session handling: always resume active session; block conflicting session creation in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` -- [ ] T026 [US1] Add “Resume onboarding” entry point on tenant view in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` -- [ ] T027 [US1] Add “Resume onboarding” entry point on provider connection pages in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` -- [ ] T028 [US1] Implement provider connection selection/linking in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (uses tenant-scoped `ProviderConnection`, client_secret only) -- [ ] T029 [US1] Ensure secrets are never displayed by relying on existing Provider Credential patterns in `app/Services/Providers/CredentialManager.php` (wizard renders no secret fields) +- [X] T026 [US1] Add “Resume onboarding” entry point on tenant view in `app/Filament/Resources/TenantResource/Pages/ViewTenant.php` +- [X] T027 [US1] Add “Resume onboarding” entry point on provider connection pages in `app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` -- [ ] T066 [US1] Add “Create provider connection” path inside onboarding (navigate to ProviderConnection create and return to onboarding) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` -- [ ] T067 [US1] Add consent guidance + optional “Check consent state” action in Step 4 in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (sanitized UX only; no secrets) +- [X] T028 [US1] Implement provider connection selection/linking in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (uses tenant-scoped `ProviderConnection`, client_secret only) +- [X] T029 [US1] Ensure secrets are never displayed by relying on existing Provider Credential patterns in `app/Services/Providers/CredentialManager.php` (wizard renders no secret fields) -- [ ] T030 [US1] Add “Verify permissions” onboarding task start action in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (enqueue-only, creates/reuses `OperationRun`) -- [ ] T031 [US1] Add onboarding verify-permissions job in `app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php` (writes `OnboardingEvidence` via `OnboardingEvidenceWriter`) +- [X] T066 [US1] Add “Create provider connection” path inside onboarding (navigate to ProviderConnection create and return to onboarding) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` +- [X] T067 [US1] Add consent guidance + optional “Check consent state” action in Step 4 in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (sanitized UX only; no secrets) -- [ ] T068 [US1] Add onboarding consent status job in `app/Jobs/Onboarding/OnboardingConsentStatusJob.php` (writes evidence) -- [ ] T032 [US1] Feature test: starting verify-permissions creates/reuses run + evidence in `tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php` +- [X] T030 [US1] Add “Verify permissions” onboarding task start action in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (enqueue-only, creates/reuses `OperationRun`) +- [X] T031 [US1] Add onboarding verify-permissions job in `app/Jobs/Onboarding/OnboardingVerifyPermissionsJob.php` (writes `OnboardingEvidence` via `OnboardingEvidenceWriter`) + +- [X] T068 [US1] Add onboarding consent status job in `app/Jobs/Onboarding/OnboardingConsentStatusJob.php` (writes evidence) + + +- [X] T032 [US1] Feature test: starting verify-permissions creates/reuses run + evidence in `tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php` **Checkpoint**: US1 usable as MVP. @@ -111,23 +118,27 @@ ## Phase 4: User Story 2 — Operate and recover using a task board (Priority: P ### Tests (write first) -- [ ] T033 [P] [US2] Feature test: task board visible starting step 4 in `tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php` -- [ ] T034 [P] [US2] Feature test: concurrency guard blocks second run in `tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php` -- [ ] T035 [P] [US2] Feature test: failing task shows sanitized reason + hints in `tests/Feature/Onboarding/OnboardingFixHintsTest.php` +- [X] T033 [P] [US2] Feature test: task board visible starting step 4 in `tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php` +- [X] T034 [P] [US2] Feature test: concurrency guard blocks second run in `tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php` +- [X] T035 [P] [US2] Feature test: failing task shows sanitized reason + hints in `tests/Feature/Onboarding/OnboardingFixHintsTest.php` ### Implementation -- [ ] T036 [US2] Add task board page in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` (lists catalog tasks + latest evidence) -- [ ] T037 [US2] Add task board Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php` -- [ ] T038 [US2] Implement “Start task” actions (enqueue-only) in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` using `app/Services/OperationRunService.php` identity `{tenant_id, task_type}` -- [ ] T039 [US2] Implement prerequisite evaluation + disabled actions in `app/Support/Onboarding/OnboardingTaskCatalog.php` -- [ ] T040 [US2] Implement fix-hints mapping from reason codes in `app/Support/Onboarding/OnboardingFixHints.php` +- [X] T036 [US2] Add task board page in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` (lists catalog tasks + latest evidence) +- [X] T037 [US2] Add task board Blade view in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php` -- [ ] T041 [US2] Add onboarding connection diagnostics job in `app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php` (writes evidence) -- [ ] T042 [US2] Add onboarding initial sync job in `app/Jobs/Onboarding/OnboardingInitialSyncJob.php` (writes evidence) -- [ ] T043 [US2] Ensure “View run” links use existing operation hub routing via `app/Support/OperationRunLinks.php` +- [X] T038 [US2] Implement “Start task” actions (enqueue-only) in `app/Filament/Pages/Onboarding/TenantOnboardingTaskBoard.php` using `app/Services/OperationRunService.php` identity `{tenant_id, task_type}` +- [X] T039 [US2] Implement prerequisite evaluation + disabled actions in `app/Support/Onboarding/OnboardingTaskCatalog.php` +- [X] T040 [US2] Implement fix-hints mapping from reason codes in `app/Support/Onboarding/OnboardingFixHints.php` + + +- [X] T041 [US2] Add onboarding connection diagnostics job in `app/Jobs/Onboarding/OnboardingConnectionDiagnosticsJob.php` (writes evidence) +- [X] T042 [US2] Add onboarding initial sync job in `app/Jobs/Onboarding/OnboardingInitialSyncJob.php` (writes evidence) + + +- [X] T043 [US2] Ensure “View run” links use existing operation hub routing via `app/Support/OperationRunLinks.php` **Checkpoint**: Task board supports reruns, history, prereqs, and concurrency dedupe. @@ -141,14 +152,14 @@ ## Phase 5: User Story 3 — Collaborate safely across multiple users (Priority: ### Tests (write first) -- [ ] T044 [P] [US3] Feature test: lock acquisition and read-only behavior in `tests/Feature/Onboarding/OnboardingSessionLockTest.php` -- [ ] T045 [P] [US3] Feature test: takeover allowed for Owner/Manager only in `tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php` +- [X] T044 [P] [US3] Feature test: lock acquisition and read-only behavior in `tests/Feature/Onboarding/OnboardingSessionLockTest.php` +- [X] T045 [P] [US3] Feature test: takeover allowed for Owner/Manager only in `tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php` ### Implementation -- [ ] T046 [US3] Add lock UI banner + renew-on-interaction behavior in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` -- [ ] T047 [US3] Implement takeover + handoff actions in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (capability-gated, uses `OnboardingLockService`) -- [ ] T048 [US3] Add audit log entries for takeover/handoff in `app/Services/Intune/AuditLogger.php` (new actions `onboarding.takeover`, `onboarding.handoff`) +- [X] T046 [US3] Add lock UI banner + renew-on-interaction behavior in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` +- [X] T047 [US3] Implement takeover + handoff actions in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` (capability-gated, uses `OnboardingLockService`) +- [X] T048 [US3] Add audit log entries for takeover/handoff in `app/Services/Intune/AuditLogger.php` (new actions `onboarding.takeover`, `onboarding.handoff`) **Checkpoint**: Collaboration is safe and auditable. @@ -162,12 +173,12 @@ ## Phase 6: User Story 4 — Review onboarding evidence and history (Priority: P ### Tests (write first) -- [ ] T049 [P] [US4] Feature test: readonly can view evidence list but cannot start runs in `tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php` +- [X] T049 [P] [US4] Feature test: readonly can view evidence list but cannot start runs in `tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php` ### Implementation -- [ ] T050 [US4] Add evidence history section to task board UI in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php` -- [ ] T051 [US4] Ensure global search does not expose onboarding sessions by avoiding a Resource for sessions (no changes needed outside `app/Filament/Pages/Onboarding/`) +- [X] T050 [US4] Add evidence history section to task board UI in `resources/views/filament/pages/onboarding/tenant-onboarding-task-board.blade.php` +- [X] T051 [US4] Ensure global search does not expose onboarding sessions by avoiding a Resource for sessions (no changes needed outside `app/Filament/Pages/Onboarding/`) **Checkpoint**: Evidence/history supports audit use cases. @@ -175,18 +186,18 @@ ### Implementation ## Phase 7: Polish & Cross-Cutting Concerns -- [ ] T052 [P] Add v1-to-v2 credential migration action in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` (move `Tenant.app_client_secret` into `provider_credentials`) -- [ ] T053 Add v1 migration UI action (Owner only, requires confirmation) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` -- [ ] T054 Update tenant creation flow to steer into onboarding in `app/Filament/Resources/TenantResource/Pages/CreateTenant.php` (redirect to wizard; prevent credential setup outside onboarding) +- [X] T052 [P] Add v1-to-v2 credential migration action in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` (move `Tenant.app_client_secret` into `provider_credentials`) +- [X] T053 Add v1 migration UI action (Owner only, requires confirmation) in `app/Filament/Pages/Onboarding/TenantOnboardingWizard.php` +- [X] T054 Update tenant creation flow to steer into onboarding in `app/Filament/Resources/TenantResource/Pages/CreateTenant.php` (redirect to wizard; prevent credential setup outside onboarding) -- [ ] T055 [P] Add regression test: no secrets rendered in onboarding pages in `tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php` -- [ ] T056 [P] Add regression test: onboarding actions use `->requiresConfirmation()` when destructive-like in `tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php` +- [X] T055 [P] Add regression test: no secrets rendered in onboarding pages in `tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php` +- [X] T056 [P] Add regression test: onboarding actions use `->requiresConfirmation()` when destructive-like in `tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php` -- [ ] T069 [P] Confirm Graph contract registry coverage for new onboarding jobs; update `config/graph_contracts.php` if any new Graph calls are introduced (and add tests) in `tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php` -- [ ] T070 [P] Implement explicit v1-to-v2 “resume” semantics (define what v1 means; create v2 session when tenant has legacy credential; migrate credential) in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` + wizard entry points +- [X] T069 [P] Confirm Graph contract registry coverage for new onboarding jobs; update `config/graph_contracts.php` if any new Graph calls are introduced (and add tests) in `tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php` +- [X] T070 [P] Implement explicit v1-to-v2 “resume” semantics (define what v1 means; create v2 session when tenant has legacy credential; migrate credential) in `app/Services/Onboarding/LegacyTenantCredentialMigrator.php` + wizard entry points -- [ ] T057 Run formatter on changed files (Pint) via `composer.json` scripts (validate using `vendor/bin/sail bin pint`) -- [ ] T058 Run onboarding test subset via `tests/Feature/Onboarding/` using `vendor/bin/sail artisan test --compact` +- [X] T057 Run formatter on changed files (Pint) via `composer.json` scripts (validate using `vendor/bin/sail bin pint`) +- [X] T058 Run onboarding test subset via `tests/Feature/Onboarding/` using `vendor/bin/sail artisan test --compact` --- diff --git a/tests/Feature/BackupItemReaddTest.php b/tests/Feature/BackupItemReaddTest.php index b077fd5..3e63641 100644 --- a/tests/Feature/BackupItemReaddTest.php +++ b/tests/Feature/BackupItemReaddTest.php @@ -51,19 +51,19 @@ // Get available policies (should be empty since policy is already in backup) $existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all(); - + expect($existingPolicyIds)->toContain($this->policy->id); - + // Soft-delete the backup item $backupItem->delete(); - + // Verify it's soft-deleted expect($this->backupSet->items()->count())->toBe(0); expect($this->backupSet->items()->withTrashed()->count())->toBe(1); - + // Get available policies again - soft-deleted items should NOT be in the list (UI can re-add them) $existingPolicyIds = $this->backupSet->items()->pluck('policy_id')->filter()->all(); - + expect($existingPolicyIds)->not->toContain($this->policy->id) ->and($existingPolicyIds)->toHaveCount(0); }); @@ -86,7 +86,7 @@ // Try to add the same policy again via BackupService $service = app(BackupService::class); - + $result = $service->addPoliciesToSet( tenant: $this->tenant, backupSet: $this->backupSet->refresh(), @@ -129,7 +129,7 @@ // Check available policies - should include the new one but not the deleted one $existingPolicyIds = $this->backupSet->items()->withTrashed()->pluck('policy_id')->filter()->all(); - + expect($existingPolicyIds)->toContain($this->policy->id) ->and($existingPolicyIds)->not->toContain($otherPolicy->id); }); diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index 74b990b..014ff58 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -11,6 +11,8 @@ uses(RefreshDatabase::class); test('policy sync updates selected policies from graph and updates the operation run', function () { + config(['graph.enabled' => true]); + $tenant = Tenant::factory()->create([ 'status' => 'active', ]); diff --git a/tests/Feature/BulkUnignorePoliciesTest.php b/tests/Feature/BulkUnignorePoliciesTest.php index 29e029b..62426b0 100644 --- a/tests/Feature/BulkUnignorePoliciesTest.php +++ b/tests/Feature/BulkUnignorePoliciesTest.php @@ -5,7 +5,6 @@ use App\Models\Policy; use App\Models\Tenant; use App\Models\User; -use App\Services\OperationRunService; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); diff --git a/tests/Feature/Filament/BackupSetUiEnforcementTest.php b/tests/Feature/Filament/BackupSetUiEnforcementTest.php index 5dcb540..4dc29f4 100644 --- a/tests/Feature/Filament/BackupSetUiEnforcementTest.php +++ b/tests/Feature/Filament/BackupSetUiEnforcementTest.php @@ -4,7 +4,6 @@ use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets; use App\Models\BackupSet; use App\Models\Tenant; -use App\Models\User; use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -68,4 +67,3 @@ expect($backupSet->fresh()->trashed())->toBeTrue(); }); - diff --git a/tests/Feature/Filament/RestoreRunUiEnforcementTest.php b/tests/Feature/Filament/RestoreRunUiEnforcementTest.php index f438596..5f0808b 100644 --- a/tests/Feature/Filament/RestoreRunUiEnforcementTest.php +++ b/tests/Feature/Filament/RestoreRunUiEnforcementTest.php @@ -80,4 +80,3 @@ expect($restoreRun->fresh()->trashed())->toBeTrue(); }); - diff --git a/tests/Feature/Filament/TenantActionsAuthorizationTest.php b/tests/Feature/Filament/TenantActionsAuthorizationTest.php index e3233ec..b59e751 100644 --- a/tests/Feature/Filament/TenantActionsAuthorizationTest.php +++ b/tests/Feature/Filament/TenantActionsAuthorizationTest.php @@ -7,8 +7,8 @@ use App\Support\Auth\UiTooltips; use Filament\Facades\Filament; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Http; use Livewire\Livewire; uses(RefreshDatabase::class); diff --git a/tests/Feature/Onboarding/OnboardingAuthorizationTest.php b/tests/Feature/Onboarding/OnboardingAuthorizationTest.php new file mode 100644 index 0000000..7ab8226 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingAuthorizationTest.php @@ -0,0 +1,20 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $nonMember = User::factory()->create(); + $this->actingAs($nonMember); + + $this->get($onboardingUrl) + ->assertNotFound(); +}); diff --git a/tests/Feature/Onboarding/OnboardingConnectionDiagnosticsJobTest.php b/tests/Feature/Onboarding/OnboardingConnectionDiagnosticsJobTest.php new file mode 100644 index 0000000..6f7eab8 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingConnectionDiagnosticsJobTest.php @@ -0,0 +1,69 @@ +for($tenant) + ->create([ + 'provider' => 'microsoft', + 'status' => 'needs_consent', + 'health_status' => 'unknown', + 'is_default' => true, + ]); + + $session = OnboardingSession::factory() + ->for($tenant) + ->create([ + 'status' => 'in_progress', + 'current_step' => 4, + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'type' => OnboardingTaskType::ConnectionDiagnostics, + ]); + + $job = new OnboardingConnectionDiagnosticsJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $session->getKey(), + operationRun: $run, + ); + + $job->handle( + evidence: app(OnboardingEvidenceWriter::class), + runs: app(OperationRunService::class), + ); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->where('onboarding_session_id', $session->getKey()) + ->where('task_type', OnboardingTaskType::ConnectionDiagnostics) + ->latest('id') + ->first(); + + expect($evidence)->not->toBeNull(); + expect($evidence?->status)->toBe('blocked'); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('failed'); +}); diff --git a/tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php b/tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php new file mode 100644 index 0000000..1797aad --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingConsentGuidanceTest.php @@ -0,0 +1,42 @@ +create([ + 'status' => 'active', + 'app_client_secret' => 'should-not-leak', + ]); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $owner = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $owner->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + OnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'in_progress', + 'current_step' => 4, + ]); + + $this->actingAs($owner); + + $this->get($onboardingUrl) + ->assertSuccessful() + ->assertSee('consent', escape: false) + ->assertDontSee('should-not-leak', escape: false); +}); diff --git a/tests/Feature/Onboarding/OnboardingConsentStatusJobTest.php b/tests/Feature/Onboarding/OnboardingConsentStatusJobTest.php new file mode 100644 index 0000000..c5663b3 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingConsentStatusJobTest.php @@ -0,0 +1,69 @@ +for($tenant) + ->create([ + 'status' => 'connected', + 'health_status' => 'ok', + ]); + + $session = OnboardingSession::factory() + ->for($tenant) + ->create([ + 'provider_connection_id' => $connection->getKey(), + 'assigned_to_user_id' => $user->getKey(), + 'status' => 'in_progress', + 'current_step' => 4, + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => OnboardingTaskType::ConsentStatus, + 'status' => OperationRunStatus::Queued->value, + 'outcome' => OperationRunOutcome::Pending->value, + ]); + + $job = new OnboardingConsentStatusJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $session->getKey(), + operationRun: $run, + ); + + $job->handle( + evidence: app(\App\Services\Onboarding\OnboardingEvidenceWriter::class), + runs: app(\App\Services\OperationRunService::class), + ); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->where('onboarding_session_id', $session->getKey()) + ->where('provider_connection_id', $connection->getKey()) + ->where('task_type', OnboardingTaskType::ConsentStatus) + ->orderByDesc('id') + ->first(); + + expect($evidence)->not->toBeNull(); + expect($evidence?->status)->toBe('ok'); + + $run->refresh(); + expect($run->status)->toBe(OperationRunStatus::Completed->value); + expect($run->outcome)->toBe(OperationRunOutcome::Succeeded->value); +}); diff --git a/tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php b/tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php new file mode 100644 index 0000000..ed219e2 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingCreateProviderConnectionTest.php @@ -0,0 +1,33 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $owner = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $owner->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($owner); + + $response = $this->get($onboardingUrl) + ->assertSuccessful(); + + $response->assertSee(CreateProviderConnection::getUrl(tenant: $tenant), escape: false); +}); diff --git a/tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php b/tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php new file mode 100644 index 0000000..c1bd92f --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingDestructiveActionConfirmationTest.php @@ -0,0 +1,73 @@ +for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $session = OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider_connection_id' => $connection->getKey(), + 'status' => 'in_progress', + 'current_step' => 4, + 'assigned_to_user_id' => $userA->getKey(), + 'locked_by_user_id' => $userB->getKey(), + 'locked_until' => now()->addMinutes(10), + 'metadata' => [], + ]); + + $this->actingAs($userA); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->assertSuccessful() + ->assertSet('session.id', (int) $session->getKey()) + ->assertActionVisible('takeover_onboarding_session') + ->mountAction('takeover_onboarding_session') + ->assertActionMounted('takeover_onboarding_session'); +}); + +it('mounts legacy credential migration action for modal confirmation', function () { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->forceFill([ + 'app_client_id' => '00000000-0000-0000-0000-000000000000', + 'app_client_secret' => 'TENANT_SECRET_NOT_RENDERED', + ])->save(); + + $connection = ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider_connection_id' => $connection->getKey(), + 'status' => 'in_progress', + 'current_step' => 2, + 'assigned_to_user_id' => $user->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->assertSuccessful() + ->assertActionVisible('migrate_legacy_credentials') + ->mountAction('migrate_legacy_credentials') + ->assertActionMounted('migrate_legacy_credentials'); +}); diff --git a/tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php b/tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php new file mode 100644 index 0000000..f73344f --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingDuplicateHandlingTest.php @@ -0,0 +1,39 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $owner = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $owner->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + OnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'draft', + ]); + + $this->actingAs($owner); + + $this->get($onboardingUrl) + ->assertSuccessful(); + + expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count()) + ->toBe(1); +}); diff --git a/tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php b/tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php new file mode 100644 index 0000000..c93c1dd --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingEvidenceReadonlyTest.php @@ -0,0 +1,55 @@ +for($tenant) + ->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $session = OnboardingSession::factory() + ->for($tenant) + ->create([ + 'status' => 'in_progress', + 'current_step' => 4, + 'provider_connection_id' => (int) $connection->getKey(), + 'assigned_to_user_id' => (int) $readonly->getKey(), + ]); + + $evidence = OnboardingEvidence::factory() + ->for($tenant) + ->create([ + 'onboarding_session_id' => (int) $session->getKey(), + 'provider_connection_id' => (int) $connection->getKey(), + 'task_type' => OnboardingTaskType::VerifyPermissions, + 'status' => 'error', + 'reason_code' => 'provider_auth_failed', + 'message' => 'Authentication failed. Please re-consent the app.', + ]); + + $this->actingAs($readonly); + Filament::setTenant($tenant, true); + + $this->get(TenantOnboardingTaskBoard::getUrl(tenant: $tenant)) + ->assertSuccessful() + ->assertSee('Evidence history') + ->assertSee($evidence->reason_code) + ->assertSee($evidence->message); + + Livewire::test(TenantOnboardingTaskBoard::class) + ->call('startTask', OnboardingTaskType::VerifyPermissions) + ->assertStatus(403); +}); diff --git a/tests/Feature/Onboarding/OnboardingFixHintsTest.php b/tests/Feature/Onboarding/OnboardingFixHintsTest.php new file mode 100644 index 0000000..7499e6b --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingFixHintsTest.php @@ -0,0 +1,58 @@ +for($tenant) + ->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $session = OnboardingSession::factory() + ->for($tenant) + ->create([ + 'status' => 'in_progress', + 'current_step' => 4, + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + /** @var OnboardingEvidenceWriter $writer */ + $writer = app(OnboardingEvidenceWriter::class); + + $writer->record( + tenant: $tenant, + taskType: OnboardingTaskType::VerifyPermissions, + status: 'error', + reasonCode: 'permissions.missing', + message: 'Authorization: Bearer abc client_secret=supersecret user@example.com', + payload: ['client_secret' => 'supersecret'], + session: $session, + providerConnection: $connection, + operationRun: null, + recordedBy: $user, + ); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $this->get(TenantOnboardingTaskBoard::getUrl(tenant: $tenant)) + ->assertSuccessful() + ->assertSee('Fix hints') + ->assertDontSee('supersecret') + ->assertDontSee('user@example.com') + ->assertDontSee('Bearer abc'); +}); diff --git a/tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php b/tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php new file mode 100644 index 0000000..00ba53c --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingGraphContractCoverageTest.php @@ -0,0 +1,36 @@ +for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider_connection_id' => $connection->getKey(), + 'status' => 'in_progress', + 'current_step' => 4, + 'assigned_to_user_id' => $user->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + + $wizardUrl = "/admin/t/{$tenant->external_id}/onboarding"; + $taskBoardUrl = "/admin/t/{$tenant->external_id}/onboarding/tasks"; + + $this->get($wizardUrl)->assertSuccessful(); + $this->get($taskBoardUrl)->assertSuccessful(); +}); diff --git a/tests/Feature/Onboarding/OnboardingInitialSyncJobTest.php b/tests/Feature/Onboarding/OnboardingInitialSyncJobTest.php new file mode 100644 index 0000000..6d097a1 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingInitialSyncJobTest.php @@ -0,0 +1,69 @@ +for($tenant) + ->create([ + 'provider' => 'microsoft', + 'status' => 'connected', + 'health_status' => 'healthy', + 'is_default' => true, + ]); + + $session = OnboardingSession::factory() + ->for($tenant) + ->create([ + 'status' => 'in_progress', + 'current_step' => 5, + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $run = OperationRun::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'type' => OnboardingTaskType::InitialSync, + ]); + + $job = new OnboardingInitialSyncJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $session->getKey(), + operationRun: $run, + ); + + $job->handle( + evidence: app(OnboardingEvidenceWriter::class), + runs: app(OperationRunService::class), + ); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->where('onboarding_session_id', $session->getKey()) + ->where('task_type', OnboardingTaskType::InitialSync) + ->latest('id') + ->first(); + + expect($evidence)->not->toBeNull(); + expect($evidence?->status)->toBe('ok'); + + $run->refresh(); + expect($run->status)->toBe('completed'); + expect($run->outcome)->toBe('succeeded'); +}); diff --git a/tests/Feature/Onboarding/OnboardingLegacyCredentialMigrationTest.php b/tests/Feature/Onboarding/OnboardingLegacyCredentialMigrationTest.php new file mode 100644 index 0000000..214ba25 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingLegacyCredentialMigrationTest.php @@ -0,0 +1,56 @@ +forceFill([ + 'app_client_id' => '00000000-0000-0000-0000-000000000000', + 'app_client_secret' => 'TENANT_SECRET_FOR_MIGRATION', + ])->save(); + + $connection = ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider_connection_id' => $connection->getKey(), + 'status' => 'in_progress', + 'current_step' => 2, + 'assigned_to_user_id' => $user->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->assertActionVisible('migrate_legacy_credentials') + ->mountAction('migrate_legacy_credentials') + ->callMountedAction() + ->assertSuccessful(); + + $credential = ProviderCredential::query() + ->where('provider_connection_id', $connection->getKey()) + ->first(); + + expect($credential)->not->toBeNull(); + expect($credential?->type)->toBe('client_secret'); + expect($credential?->payload)->toBe([ + 'client_id' => '00000000-0000-0000-0000-000000000000', + 'client_secret' => 'TENANT_SECRET_FOR_MIGRATION', + ]); + + $tenant->refresh(); + expect($tenant->app_client_secret)->toBe('TENANT_SECRET_FOR_MIGRATION'); +}); diff --git a/tests/Feature/Onboarding/OnboardingLegacyResumeSemanticsTest.php b/tests/Feature/Onboarding/OnboardingLegacyResumeSemanticsTest.php new file mode 100644 index 0000000..f7160e7 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingLegacyResumeSemanticsTest.php @@ -0,0 +1,42 @@ +forceFill([ + 'app_client_id' => '00000000-0000-0000-0000-000000000000', + 'app_client_secret' => 'TENANT_SECRET_FOR_RESUME', + ])->save(); + + $connection = ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $session = OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider_connection_id' => null, + 'status' => 'draft', + 'current_step' => 1, + 'assigned_to_user_id' => $user->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->assertSuccessful(); + + $session->refresh(); + + expect($session->provider_connection_id)->toBe((int) $connection->getKey()); +}); diff --git a/tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php b/tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php new file mode 100644 index 0000000..b22ba91 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingNoSecretsLeakTest.php @@ -0,0 +1,53 @@ +forceFill([ + 'app_client_id' => '00000000-0000-0000-0000-000000000000', + 'app_client_secret' => 'TENANT_SECRET_SHOULD_NEVER_RENDER', + ])->save(); + + $connection = ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + ProviderCredential::factory()->for($connection, 'providerConnection')->create([ + 'type' => 'client_secret', + 'payload' => [ + 'client_id' => '11111111-1111-1111-1111-111111111111', + 'client_secret' => 'PROVIDER_SECRET_SHOULD_NEVER_RENDER', + ], + ]); + + OnboardingSession::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider_connection_id' => $connection->getKey(), + 'status' => 'in_progress', + 'current_step' => 4, + 'assigned_to_user_id' => $user->getKey(), + 'metadata' => [], + ]); + + $this->actingAs($user); + + $wizardUrl = "/admin/t/{$tenant->external_id}/onboarding"; + $taskBoardUrl = "/admin/t/{$tenant->external_id}/onboarding/tasks"; + + $this->get($wizardUrl) + ->assertSuccessful() + ->assertDontSee('TENANT_SECRET_SHOULD_NEVER_RENDER') + ->assertDontSee('PROVIDER_SECRET_SHOULD_NEVER_RENDER'); + + $this->get($taskBoardUrl) + ->assertSuccessful() + ->assertDontSee('TENANT_SECRET_SHOULD_NEVER_RENDER') + ->assertDontSee('PROVIDER_SECRET_SHOULD_NEVER_RENDER'); +}); diff --git a/tests/Feature/Onboarding/OnboardingPlanPreviewTest.php b/tests/Feature/Onboarding/OnboardingPlanPreviewTest.php new file mode 100644 index 0000000..5ea8669 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingPlanPreviewTest.php @@ -0,0 +1,31 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $owner = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $owner->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($owner); + + $this->get($onboardingUrl) + ->assertSuccessful() + ->assertSee('Verify permissions'); +}); diff --git a/tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php b/tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php new file mode 100644 index 0000000..4b6a0ed --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingReadonlyAccessTest.php @@ -0,0 +1,34 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $readonly = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $readonly->getKey(), + 'role' => 'readonly', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($readonly); + + $this->get($onboardingUrl) + ->assertSuccessful(); + + expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count()) + ->toBe(0); +}); diff --git a/tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php b/tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php new file mode 100644 index 0000000..686a10c --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingRoleGuidanceTest.php @@ -0,0 +1,38 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $readonly = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $readonly->getKey(), + 'role' => 'readonly', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + OnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'status' => 'in_progress', + 'current_step' => 4, + ]); + + $this->actingAs($readonly); + + $this->get($onboardingUrl) + ->assertSuccessful() + ->assertSee('permission', escape: false); +}); diff --git a/tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php b/tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php new file mode 100644 index 0000000..b51e4a0 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingSessionLifecycleTest.php @@ -0,0 +1,40 @@ +create(['status' => 'active']); + $onboardingUrl = "/admin/t/{$tenant->external_id}/onboarding"; + + $owner = User::factory()->create(); + TenantMembership::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $owner->getKey(), + 'role' => 'owner', + 'source' => 'manual', + 'source_ref' => null, + 'created_by_user_id' => null, + ]); + + $this->actingAs($owner); + + $this->get($onboardingUrl) + ->assertSuccessful(); + + expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count()) + ->toBe(1); + + $this->get($onboardingUrl) + ->assertSuccessful(); + + expect(OnboardingSession::query()->where('tenant_id', $tenant->getKey())->count()) + ->toBe(1); +}); diff --git a/tests/Feature/Onboarding/OnboardingSessionLockTest.php b/tests/Feature/Onboarding/OnboardingSessionLockTest.php new file mode 100644 index 0000000..44f1e31 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingSessionLockTest.php @@ -0,0 +1,56 @@ +create(); + createUserWithTenant(tenant: $tenant, user: $userB, role: 'operator'); + + $connectionA = ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $connectionB = ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => false, + ]); + + $this->actingAs($userA); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->set('selectedProviderConnectionId', (int) $connectionA->getKey()) + ->assertSuccessful(); + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + expect($session)->not->toBeNull(); + expect($session?->provider_connection_id)->toBe((int) $connectionA->getKey()); + expect($session?->locked_by_user_id)->toBe((int) $userA->getKey()); + expect($session?->locked_until)->not->toBeNull(); + expect($session?->locked_until?->isFuture())->toBeTrue(); + + $this->actingAs($userB); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->set('selectedProviderConnectionId', (int) $connectionB->getKey()) + ->assertSuccessful(); + + $session->refresh(); + + expect($session->provider_connection_id)->toBe((int) $connectionA->getKey()); + expect($session->locked_by_user_id)->toBe((int) $userA->getKey()); +}); diff --git a/tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php b/tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php new file mode 100644 index 0000000..f1d71e2 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingSessionTakeoverAuthorizationTest.php @@ -0,0 +1,59 @@ +create(); + createUserWithTenant(tenant: $tenant, user: $actor, role: $role); + + ProviderConnection::factory()->for($tenant)->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $this->actingAs($lockHolder); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->assertSuccessful(); + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + expect($session)->not->toBeNull(); + expect($session?->locked_by_user_id)->toBe((int) $lockHolder->getKey()); + + $this->actingAs($actor); + Filament::setTenant($tenant, true); + + if (! $shouldBeAllowed) { + Livewire::test(TenantOnboardingWizard::class) + ->callAction('takeover_onboarding_session'); + + $session->refresh(); + expect($session->locked_by_user_id)->toBe((int) $lockHolder->getKey()); + + return; + } + + Livewire::test(TenantOnboardingWizard::class) + ->callAction('takeover_onboarding_session'); + + $session->refresh(); + expect($session->locked_by_user_id)->toBe((int) $actor->getKey()); +})->with([ + 'owner' => ['owner', true], + 'manager' => ['manager', true], + 'operator' => ['operator', false], + 'readonly' => ['readonly', false], +]); diff --git a/tests/Feature/Onboarding/OnboardingSmokeTest.php b/tests/Feature/Onboarding/OnboardingSmokeTest.php new file mode 100644 index 0000000..e41276f --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingSmokeTest.php @@ -0,0 +1,5 @@ +toBeTrue(); +}); diff --git a/tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php b/tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php new file mode 100644 index 0000000..eb75462 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingTaskBoardVisibilityTest.php @@ -0,0 +1,45 @@ +for($tenant) + ->create([ + 'status' => 'in_progress', + 'current_step' => 3, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + $wizardUrl = TenantOnboardingWizard::getUrl(tenant: $tenant); + $taskBoardUrl = TenantOnboardingTaskBoard::getUrl(tenant: $tenant); + + $this->get($wizardUrl) + ->assertSuccessful() + ->assertDontSee('Open task board'); + + $this->get($taskBoardUrl) + ->assertRedirect($wizardUrl); + + $session->update(['current_step' => 4]); + + $this->get($wizardUrl) + ->assertSuccessful() + ->assertSee('Open task board'); + + $this->get($taskBoardUrl) + ->assertSuccessful() + ->assertSee('Onboarding task board'); +}); diff --git a/tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php b/tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php new file mode 100644 index 0000000..a2d1508 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingTaskConcurrencyTest.php @@ -0,0 +1,68 @@ +for($tenant) + ->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $session = OnboardingSession::factory() + ->for($tenant) + ->create([ + 'status' => 'in_progress', + 'current_step' => 4, + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingTaskBoard::class) + ->call('startTask', OnboardingTaskType::VerifyPermissions) + ->assertSuccessful(); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', OnboardingTaskType::VerifyPermissions) + ->count() + )->toBe(1); + + Queue::assertPushed(OnboardingVerifyPermissionsJob::class); + + // Attempt to start again while still active should dedupe. + Livewire::test(TenantOnboardingTaskBoard::class) + ->call('startTask', OnboardingTaskType::VerifyPermissions) + ->assertSuccessful(); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', OnboardingTaskType::VerifyPermissions) + ->count() + )->toBe(1); + + expect(Queue::pushed(OnboardingVerifyPermissionsJob::class))->toHaveCount(1); + + // Keep session referenced so it can't be optimized away. + expect($session->getKey())->toBeInt(); +}); diff --git a/tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php b/tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php new file mode 100644 index 0000000..7167e43 --- /dev/null +++ b/tests/Feature/Onboarding/OnboardingVerifyPermissionsTaskTest.php @@ -0,0 +1,102 @@ +pluck('key') + ->filter() + ->values() + ->all(); + + config()->set('intune_permissions.granted_stub', $requiredKeys); + + $connection = ProviderConnection::factory() + ->for($tenant) + ->create([ + 'provider' => 'microsoft', + 'is_default' => true, + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(TenantOnboardingWizard::class) + ->set('selectedProviderConnectionId', (int) $connection->getKey()) + ->call('startVerifyPermissions') + ->assertSuccessful(); + + $session = OnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->first(); + + expect($session)->not->toBeNull(); + + $run = OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', OnboardingTaskType::VerifyPermissions) + ->first(); + + expect($run)->not->toBeNull(); + + Queue::assertPushed(OnboardingVerifyPermissionsJob::class); + + // Calling start again while the run is still active should dedupe. + Livewire::test(TenantOnboardingWizard::class) + ->call('startVerifyPermissions') + ->assertSuccessful(); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', OnboardingTaskType::VerifyPermissions) + ->count() + )->toBe(1); + + expect(Queue::pushed(OnboardingVerifyPermissionsJob::class))->toHaveCount(1); + + // Execute the job inline to assert evidence write behavior. + $job = new OnboardingVerifyPermissionsJob( + tenantId: (int) $tenant->getKey(), + userId: (int) $user->getKey(), + providerConnectionId: (int) $connection->getKey(), + onboardingSessionId: (int) $session->getKey(), + operationRun: $run, + ); + + $job->handle( + permissions: app(\App\Services\Intune\TenantPermissionService::class), + evidence: app(\App\Services\Onboarding\OnboardingEvidenceWriter::class), + runs: app(\App\Services\OperationRunService::class), + ); + + expect(OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->where('onboarding_session_id', $session->getKey()) + ->where('task_type', OnboardingTaskType::VerifyPermissions) + ->exists() + )->toBeTrue(); + + $evidence = OnboardingEvidence::query() + ->where('tenant_id', $tenant->getKey()) + ->where('onboarding_session_id', $session->getKey()) + ->where('task_type', OnboardingTaskType::VerifyPermissions) + ->orderByDesc('id') + ->first(); + + expect($evidence?->status)->toBe('ok'); +}); diff --git a/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php b/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php index 5f98ccd..5390659 100644 --- a/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php +++ b/tests/Unit/Auth/UiEnforcementBulkPreflightQueryCountTest.php @@ -33,4 +33,3 @@ expect($enforcement->bulkSelectionIsAuthorized($user, $tenants))->toBeTrue(); expect($membershipQueries)->toBe(1); }); - diff --git a/tests/Unit/Auth/UiEnforcementTest.php b/tests/Unit/Auth/UiEnforcementTest.php index 74b4284..d28b1be 100644 --- a/tests/Unit/Auth/UiEnforcementTest.php +++ b/tests/Unit/Auth/UiEnforcementTest.php @@ -125,4 +125,3 @@ expect($enforcement->bulkSelectionIsAuthorized($user, collect([$tenantA, $tenantB])))->toBeTrue(); }); - diff --git a/tests/Unit/Badges/OnboardingBadgesTest.php b/tests/Unit/Badges/OnboardingBadgesTest.php new file mode 100644 index 0000000..32df0c8 --- /dev/null +++ b/tests/Unit/Badges/OnboardingBadgesTest.php @@ -0,0 +1,24 @@ +color)->toBe('success'); + expect($ok->label)->toBe('OK'); + + $warn = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'warn'); + expect($warn->color)->toBe('warning'); + expect($warn->label)->toBe('Warning'); + + $fail = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'fail'); + expect($fail->color)->toBe('danger'); + expect($fail->label)->toBe('Failed'); + + $unknown = BadgeCatalog::spec(BadgeDomain::OnboardingTaskStatus, 'unknown'); + expect($unknown->color)->toBe('gray'); + expect($unknown->label)->toBe('Unknown'); +}); diff --git a/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php index 76fb892..04674da 100644 --- a/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php +++ b/tests/Unit/BulkPolicyVersionForceDeleteJobTest.php @@ -1,11 +1,11 @@ create(); + + $writer = app(OnboardingEvidenceWriter::class); + + $evidence = $writer->record( + tenant: $tenant, + taskType: 'onboarding.permissions.verify', + status: 'fail', + reasonCode: 'invalid_client', + message: 'Authorization: Bearer abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz', + payload: [ + 'access_token' => 'super-secret-token', + 'nested' => [ + 'client_secret' => 'dont-store-this', + 'detail' => 'user@example.com', + ], + ], + ); + + expect($evidence)->toBeInstanceOf(OnboardingEvidence::class); + expect($evidence->tenant_id)->toBe($tenant->getKey()); + + expect($evidence->reason_code)->toBe('provider_auth_failed'); + expect($evidence->message)->toContain('[REDACTED_AUTH]'); + + expect($evidence->payload['access_token'])->toBe('[REDACTED]'); + expect($evidence->payload['nested']['client_secret'])->toBe('[REDACTED]'); + expect($evidence->payload['nested']['detail'])->toBe('[REDACTED_EMAIL]'); +}); diff --git a/tests/Unit/Onboarding/OnboardingLockServiceTest.php b/tests/Unit/Onboarding/OnboardingLockServiceTest.php new file mode 100644 index 0000000..5a59609 --- /dev/null +++ b/tests/Unit/Onboarding/OnboardingLockServiceTest.php @@ -0,0 +1,47 @@ +create(); + $userB = User::factory()->create(); + + $session = OnboardingSession::factory()->create([ + 'locked_by_user_id' => null, + 'locked_until' => null, + ]); + + $locks = app(OnboardingLockService::class); + + expect($locks->acquire($session, $userA, ttlSeconds: 600))->toBeTrue(); + + $session->refresh(); + expect((int) $session->locked_by_user_id)->toBe((int) $userA->getKey()); + expect($session->locked_until)->not->toBeNull(); + + expect($locks->acquire($session, $userB, ttlSeconds: 600))->toBeFalse(); +}); + +it('allows takeover', function (): void { + $userA = User::factory()->create(); + $userB = User::factory()->create(); + + $session = OnboardingSession::factory()->create([ + 'locked_by_user_id' => $userA->getKey(), + 'locked_until' => now()->addMinutes(5), + ]); + + $locks = app(OnboardingLockService::class); + + $locks->takeover($session, $userB, ttlSeconds: 600); + + $session->refresh(); + expect((int) $session->locked_by_user_id)->toBe((int) $userB->getKey()); +});