*/ public array $data = []; /** * @var array */ public array $selectedBootstrapOperationTypes = []; /** * @return array */ protected function getHeaderActions(): array { return []; } public function mount(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); if ($workspaceId === null) { $this->redirect('/admin/choose-workspace'); return; } $workspace = Workspace::query()->whereKey($workspaceId)->first(); if (! $workspace instanceof Workspace) { abort(404); } if (! app(WorkspaceContext::class)->isMember($user, $workspace)) { abort(404); } $this->workspace = $workspace; $this->resumeLatestOnboardingSessionIfUnambiguous(); $this->initializeWizardData(); } public function content(Schema $schema): Schema { return $schema ->statePath('data') ->schema([ Wizard::make([ Step::make('Identify managed tenant') ->description('Create or resume a managed tenant in this workspace.') ->schema([ Section::make('Tenant') ->schema([ TextInput::make('entra_tenant_id') ->label('Entra Tenant ID (GUID)') ->required() ->rules(['uuid']) ->maxLength(255), Select::make('environment') ->label('Environment') ->required() ->options([ 'prod' => 'Production', 'staging' => 'Staging', 'dev' => 'Development', 'other' => 'Other', ]) ->default('other'), TextInput::make('name') ->label('Display name') ->required() ->maxLength(255), TextInput::make('primary_domain') ->label('Primary domain (optional)') ->maxLength(255), Textarea::make('notes') ->label('Notes (optional)') ->rows(3) ->maxLength(2000), ]), ]) ->afterValidation(function (): void { $entraTenantId = (string) ($this->data['entra_tenant_id'] ?? ''); $environment = (string) ($this->data['environment'] ?? 'other'); $tenantName = (string) ($this->data['name'] ?? ''); $primaryDomain = (string) ($this->data['primary_domain'] ?? ''); $notes = (string) ($this->data['notes'] ?? ''); try { $this->identifyManagedTenant([ 'entra_tenant_id' => $entraTenantId, 'environment' => $environment, 'name' => $tenantName, 'primary_domain' => $primaryDomain, 'notes' => $notes, ]); } catch (NotFoundHttpException) { Notification::make() ->title('Tenant not available') ->body('This tenant cannot be onboarded in this workspace.') ->danger() ->send(); throw new Halt; } $this->initializeWizardData(); }), Step::make('Provider connection') ->description('Select an existing connection or create a new one.') ->schema([ Section::make('Connection') ->schema([ Radio::make('connection_mode') ->label('Mode') ->options([ 'existing' => 'Use existing connection', 'new' => 'Create new connection', ]) ->required() ->default('existing') ->live(), Select::make('provider_connection_id') ->label('Provider connection') ->required(fn (Get $get): bool => $get('connection_mode') === 'existing') ->options(fn (): array => $this->providerConnectionOptions()) ->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'), TextInput::make('new_connection.display_name') ->label('Display name') ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), TextInput::make('new_connection.client_id') ->label('Client ID') ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), TextInput::make('new_connection.client_secret') ->label('Client secret') ->password() ->required(fn (Get $get): bool => $get('connection_mode') === 'new') ->maxLength(255) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new') ->helperText('Stored encrypted and never shown again.'), Toggle::make('new_connection.is_default') ->label('Make default') ->default(true) ->visible(fn (Get $get): bool => $get('connection_mode') === 'new'), ]), ]) ->afterValidation(function (): void { if (! $this->managedTenant instanceof Tenant) { throw new Halt; } $mode = (string) ($this->data['connection_mode'] ?? 'existing'); if ($mode === 'new') { $new = is_array($this->data['new_connection'] ?? null) ? $this->data['new_connection'] : []; $this->createProviderConnection([ 'display_name' => (string) ($new['display_name'] ?? ''), 'client_id' => (string) ($new['client_id'] ?? ''), 'client_secret' => (string) ($new['client_secret'] ?? ''), 'is_default' => (bool) ($new['is_default'] ?? true), ]); if (is_array($this->data['new_connection'] ?? null)) { $this->data['new_connection']['client_secret'] = null; } } else { $providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0); if ($providerConnectionId <= 0) { throw new Halt; } $this->selectProviderConnection($providerConnectionId); } $this->touchOnboardingSessionStep('connection'); $this->initializeWizardData(); }), Step::make('Verify access') ->description('Run a queued verification check (Operation Run).') ->schema([ Section::make('Verification') ->schema([ Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel()) ->badge() ->color(fn (): string => $this->verificationStatusColor()), Text::make('Verification is in progress. Use “Refresh” to see the latest stored status.') ->visible(fn (): bool => $this->verificationStatus() === 'in_progress'), SchemaActions::make([ Action::make('wizardStartVerification') ->label('Start verification') ->visible(fn (): bool => $this->managedTenant instanceof Tenant) ->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START) ? null : 'You do not have permission to start verification.') ->action(fn () => $this->startVerification()), Action::make('wizardRefreshVerification') ->label('Refresh') ->visible(fn (): bool => $this->verificationRunUrl() !== null) ->action(fn () => $this->refreshVerificationStatus()), ]), ViewField::make('verification_report') ->label('') ->default(null) ->view('filament.forms.components.managed-tenant-onboarding-verification-report') ->viewData(fn (): array => $this->verificationReportViewData()) ->visible(fn (): bool => $this->verificationRunUrl() !== null), ]), ]) ->beforeValidation(function (): void { if (! $this->verificationHasSucceeded()) { Notification::make() ->title('Verification required') ->body('Run verification successfully before continuing.') ->warning() ->send(); throw new Halt; } $this->touchOnboardingSessionStep('verify'); }), Step::make('Bootstrap (optional)') ->description('Optionally start inventory and compliance operations.') ->schema([ Section::make('Bootstrap') ->schema([ CheckboxList::make('bootstrap_operation_types') ->label('Bootstrap actions') ->options(fn (): array => $this->bootstrapOperationOptions()) ->columns(1), SchemaActions::make([ Action::make('wizardStartBootstrap') ->label('Start bootstrap') ->visible(fn (): bool => $this->managedTenant instanceof Tenant) ->disabled(fn (): bool => ! $this->canStartAnyBootstrap()) ->tooltip(fn (): ?string => $this->canStartAnyBootstrap() ? null : 'You do not have permission to start bootstrap actions.') ->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))), ]), Text::make(fn (): string => $this->bootstrapRunsLabel()) ->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''), ]), ]) ->afterValidation(function (): void { $types = $this->data['bootstrap_operation_types'] ?? []; $this->selectedBootstrapOperationTypes = is_array($types) ? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')) : []; $this->touchOnboardingSessionStep('bootstrap'); }), Step::make('Complete') ->description('Activate the tenant and finish onboarding.') ->schema([ Section::make('Finish') ->schema([ Text::make(fn (): string => $this->managedTenant instanceof Tenant ? 'Tenant: '.$this->managedTenant->name : 'Tenant: not selected') ->badge() ->color('gray'), Text::make(fn (): string => 'Verification: '.$this->verificationStatusLabel()) ->badge() ->color(fn (): string => $this->verificationStatusColor()), Toggle::make('override_blocked') ->label('Override blocked verification') ->helperText('Owner-only. Requires a reason and will be recorded in the audit log.') ->visible(fn (): bool => $this->verificationStatus() === 'blocked' && $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)), Textarea::make('override_reason') ->label('Override reason') ->required(fn (Get $get): bool => (bool) $get('override_blocked')) ->visible(fn (Get $get): bool => (bool) $get('override_blocked')) ->rows(3) ->maxLength(500), SchemaActions::make([ Action::make('wizardCompleteOnboarding') ->label('Complete onboarding') ->color('success') ->disabled(fn (): bool => ! $this->canCompleteOnboarding() || ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) ->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE) ? null : 'Owner required to activate.') ->action(fn () => $this->completeOnboarding()), ]), ]), ]) ->beforeValidation(function (): void { if (! $this->canCompleteOnboarding()) { throw new Halt; } }), ]) ->startOnStep(fn (): int => $this->computeWizardStartStep()) ->skippable(false), ]); } private function resumeLatestOnboardingSessionIfUnambiguous(): void { $sessionCount = TenantOnboardingSession::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereNull('completed_at') ->count(); if ($sessionCount !== 1) { return; } $session = TenantOnboardingSession::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereNull('completed_at') ->orderByDesc('updated_at') ->first(); if (! $session instanceof TenantOnboardingSession) { return; } $tenant = Tenant::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->whereKey((int) $session->tenant_id) ->first(); if (! $tenant instanceof Tenant) { return; } $this->managedTenant = $tenant; $this->onboardingSession = $session; $providerConnectionId = $session->state['provider_connection_id'] ?? null; $this->selectedProviderConnectionId = is_int($providerConnectionId) ? $providerConnectionId : $this->resolveDefaultProviderConnectionId($tenant); $bootstrapTypes = $session->state['bootstrap_operation_types'] ?? []; $this->selectedBootstrapOperationTypes = is_array($bootstrapTypes) ? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== '')) : []; } private function initializeWizardData(): void { if (! array_key_exists('connection_mode', $this->data)) { $this->data['connection_mode'] = 'existing'; } if ($this->managedTenant instanceof Tenant) { $this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id; $this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other'); $this->data['name'] ??= (string) $this->managedTenant->name; $this->data['primary_domain'] ??= (string) ($this->managedTenant->domain ?? ''); $notes = is_array($this->managedTenant->metadata) ? ($this->managedTenant->metadata['notes'] ?? null) : null; if (is_string($notes) && trim($notes) !== '') { $this->data['notes'] ??= trim($notes); } } if ($this->onboardingSession instanceof TenantOnboardingSession) { $state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : []; if (isset($state['entra_tenant_id']) && is_string($state['entra_tenant_id']) && trim($state['entra_tenant_id']) !== '') { $this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']); } if (isset($state['environment']) && is_string($state['environment']) && trim($state['environment']) !== '') { $this->data['environment'] ??= trim($state['environment']); } if (isset($state['tenant_name']) && is_string($state['tenant_name']) && trim($state['tenant_name']) !== '') { $this->data['name'] ??= trim($state['tenant_name']); } if (array_key_exists('primary_domain', $state)) { $domain = $state['primary_domain']; if (is_string($domain)) { $this->data['primary_domain'] ??= $domain; } } if (array_key_exists('notes', $state)) { $notes = $state['notes']; if (is_string($notes)) { $this->data['notes'] ??= $notes; } } $providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null; if (is_int($providerConnectionId)) { $this->data['provider_connection_id'] = $providerConnectionId; $this->selectedProviderConnectionId = $providerConnectionId; } $types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null; if (is_array($types)) { $this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== '')); } } if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) { $this->data['provider_connection_id'] = $this->selectedProviderConnectionId; } } private function computeWizardStartStep(): int { if (! $this->managedTenant instanceof Tenant) { return 1; } if (! $this->resolveSelectedProviderConnection($this->managedTenant)) { return 2; } if (! $this->verificationHasSucceeded()) { return 3; } return 4; } /** * @return array */ private function providerConnectionOptions(): array { if (! $this->managedTenant instanceof Tenant) { return []; } return ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', $this->managedTenant->getKey()) ->orderByDesc('is_default') ->orderBy('display_name') ->pluck('display_name', 'id') ->all(); } private function verificationStatusLabel(): string { return BadgeCatalog::spec( BadgeDomain::ManagedTenantOnboardingVerificationStatus, $this->verificationStatus(), )->label; } private function verificationStatus(): string { $run = $this->verificationRun(); if (! $run instanceof OperationRun) { return 'not_started'; } if ($run->status !== OperationRunStatus::Completed->value) { return 'in_progress'; } if ($run->outcome === OperationRunOutcome::Succeeded->value) { return 'ready'; } if ($run->outcome === OperationRunOutcome::PartiallySucceeded->value) { return 'needs_attention'; } if ($run->outcome !== OperationRunOutcome::Failed->value) { return 'needs_attention'; } $failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : []; if ($failures === []) { return 'blocked'; } foreach ($failures as $failure) { if (! is_array($failure)) { continue; } $reasonCode = $failure['reason_code'] ?? null; if (! is_string($reasonCode) || $reasonCode === '') { continue; } if (in_array($reasonCode, ['provider_auth_failed', 'permission_denied'], true)) { return 'blocked'; } } return 'needs_attention'; } private function verificationStatusColor(): string { return BadgeCatalog::spec( BadgeDomain::ManagedTenantOnboardingVerificationStatus, $this->verificationStatus(), )->color; } private function verificationRunUrl(): ?string { if (! $this->managedTenant instanceof Tenant) { return null; } if (! $this->onboardingSession instanceof TenantOnboardingSession) { return null; } $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; if (! is_int($runId)) { return null; } return $this->tenantlessOperationRunUrl($runId); } /** * @return array{run: array|null, runUrl: string|null} */ private function verificationReportViewData(): array { $run = $this->verificationRun(); $runUrl = $this->verificationRunUrl(); if (! $run instanceof OperationRun) { return [ 'run' => null, 'runUrl' => $runUrl, ]; } $context = is_array($run->context ?? null) ? $run->context : []; $targetScope = $context['target_scope'] ?? []; $targetScope = is_array($targetScope) ? $targetScope : []; $failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : []; return [ 'run' => [ 'id' => (int) $run->getKey(), 'type' => (string) $run->type, 'status' => (string) $run->status, 'outcome' => (string) $run->outcome, 'initiator_name' => (string) $run->initiator_name, 'started_at' => $run->started_at?->toJSON(), 'completed_at' => $run->completed_at?->toJSON(), 'target_scope' => $targetScope, 'failures' => $failures, ], 'runUrl' => $runUrl, ]; } private function bootstrapRunsLabel(): string { if (! $this->onboardingSession instanceof TenantOnboardingSession) { return ''; } $runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null; $runs = is_array($runs) ? $runs : []; if ($runs === []) { return ''; } return sprintf('Started %d bootstrap run(s).', count($runs)); } private function touchOnboardingSessionStep(string $step): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } if (! $this->onboardingSession instanceof TenantOnboardingSession) { return; } $this->onboardingSession->forceFill([ 'current_step' => $step, 'updated_by_user_id' => (int) $user->getKey(), ])->save(); } private function authorizeWorkspaceMutation(User $user, string $capability): void { $this->authorizeWorkspaceMember($user); if (! $user->can($capability, $this->workspace)) { abort(403); } } private function authorizeWorkspaceMember(User $user): void { if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) { abort(404); } } private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int { $workspaceId = DB::table('tenant_memberships') ->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id') ->where('tenant_memberships.tenant_id', (int) $tenant->getKey()) ->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END") ->value('workspace_memberships.workspace_id'); return $workspaceId === null ? null : (int) $workspaceId; } /** * @param array{entra_tenant_id: string, environment: string, name: string, primary_domain?: string, notes?: string} $data */ public function identifyManagedTenant(array $data): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY); $entraTenantId = (string) ($data['entra_tenant_id'] ?? ''); $tenantName = (string) ($data['name'] ?? ''); $environment = (string) ($data['environment'] ?? 'other'); $primaryDomain = trim((string) ($data['primary_domain'] ?? '')); $notes = trim((string) ($data['notes'] ?? '')); if ($entraTenantId === '' || $tenantName === '') { abort(422); } if (! in_array($environment, ['prod', 'staging', 'dev', 'other'], true)) { abort(422); } $primaryDomain = $primaryDomain !== '' ? $primaryDomain : null; $notes = $notes !== '' ? $notes : null; DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes): void { $auditLogger = app(WorkspaceAuditLogger::class); $membershipManager = app(TenantMembershipManager::class); $existingTenant = Tenant::query() ->withTrashed() ->where('tenant_id', $entraTenantId) ->first(); if ($existingTenant instanceof Tenant) { if ($existingTenant->trashed() || $existingTenant->status === Tenant::STATUS_ARCHIVED) { abort(404); } if ($existingTenant->workspace_id === null) { $resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant); if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) { $existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save(); } } if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } $existingTenant->forceFill([ 'name' => $tenantName, 'environment' => $environment, 'domain' => $primaryDomain, 'status' => $existingTenant->status === Tenant::STATUS_DRAFT ? Tenant::STATUS_ONBOARDING : $existingTenant->status, 'metadata' => array_merge(is_array($existingTenant->metadata) ? $existingTenant->metadata : [], array_filter([ 'notes' => $notes, ], static fn ($value): bool => $value !== null)), ])->save(); $tenant = $existingTenant; } else { try { $tenant = Tenant::query()->create([ 'workspace_id' => (int) $this->workspace->getKey(), 'name' => $tenantName, 'tenant_id' => $entraTenantId, 'domain' => $primaryDomain, 'environment' => $environment, 'status' => Tenant::STATUS_ONBOARDING, 'metadata' => array_filter([ 'notes' => $notes, ], static fn ($value): bool => $value !== null), ]); } catch (QueryException $exception) { // Race-safe global uniqueness: if another workspace created the tenant_id first, // treat it as deny-as-not-found. $existingTenant = Tenant::query() ->withTrashed() ->where('tenant_id', $entraTenantId) ->first(); if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) { $tenant = $existingTenant; } else { throw $exception; } } } $membershipManager->addMember( tenant: $tenant, actor: $user, member: $user, role: 'owner', source: 'manual', ); $ownerCount = TenantMembership::query() ->where('tenant_id', $tenant->getKey()) ->where('role', 'owner') ->count(); if ($ownerCount === 0) { throw new RuntimeException('Tenant must have at least one owner.'); } $session = TenantOnboardingSession::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('entra_tenant_id', $entraTenantId) ->whereNull('completed_at') ->first(); $sessionWasCreated = false; if (! $session instanceof TenantOnboardingSession) { $session = new TenantOnboardingSession; $session->workspace_id = (int) $this->workspace->getKey(); $session->entra_tenant_id = $entraTenantId; $session->tenant_id = (int) $tenant->getKey(); $session->started_by_user_id = (int) $user->getKey(); $sessionWasCreated = true; } $session->entra_tenant_id = $entraTenantId; $session->tenant_id = (int) $tenant->getKey(); $session->current_step = 'identify'; $session->state = array_merge($session->state ?? [], [ 'entra_tenant_id' => $entraTenantId, 'tenant_name' => $tenantName, 'environment' => $environment, 'primary_domain' => $primaryDomain, 'notes' => $notes, ]); $session->updated_by_user_id = (int) $user->getKey(); $session->save(); $this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant); if ($this->selectedProviderConnectionId !== null) { $session->state = array_merge($session->state ?? [], [ 'provider_connection_id' => (int) $this->selectedProviderConnectionId, ]); $session->save(); } $auditLogger->log( workspace: $this->workspace, action: ($sessionWasCreated ? AuditActionId::ManagedTenantOnboardingStart : AuditActionId::ManagedTenantOnboardingResume )->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'entra_tenant_id' => $entraTenantId, 'tenant_name' => $tenantName, 'onboarding_session_id' => (int) $session->getKey(), 'current_step' => (string) $session->current_step, ], ], actor: $user, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); $this->managedTenant = $tenant; $this->onboardingSession = $session; }); Notification::make() ->title('Managed tenant identified') ->success() ->send(); } public function selectProviderConnection(int $providerConnectionId): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW); if (! $this->managedTenant instanceof Tenant) { abort(404); } $connection = ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', $this->managedTenant->getKey()) ->whereKey($providerConnectionId) ->first(); if (! $connection instanceof ProviderConnection) { abort(404); } $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), ]); $this->onboardingSession->current_step = 'connection'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } Notification::make() ->title('Provider connection selected') ->success() ->send(); } /** * @param array{display_name: string, client_id: string, client_secret: string, is_default?: bool} $data */ public function createProviderConnection(array $data): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE); if (! $this->managedTenant instanceof Tenant) { abort(404); } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) { abort(404); } $displayName = trim((string) ($data['display_name'] ?? '')); $clientId = (string) ($data['client_id'] ?? ''); $clientSecret = (string) ($data['client_secret'] ?? ''); $makeDefault = (bool) ($data['is_default'] ?? false); if ($displayName === '') { abort(422); } /** @var ProviderConnection $connection */ $connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault): ProviderConnection { $connection = ProviderConnection::query()->updateOrCreate( [ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, ], [ 'workspace_id' => (int) $tenant->workspace_id, 'display_name' => $displayName, ], ); app(CredentialManager::class)->upsertClientSecretCredential( connection: $connection, clientId: $clientId, clientSecret: $clientSecret, ); if ($makeDefault) { $connection->makeDefault(); } return $connection; }); $this->selectedProviderConnectionId = (int) $connection->getKey(); if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), ]); $this->onboardingSession->current_step = 'connection'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } Notification::make() ->title('Provider connection created') ->success() ->send(); } public function startVerification(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START); if (! $this->managedTenant instanceof Tenant) { Notification::make() ->title('Identify a managed tenant first') ->warning() ->send(); return; } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('No provider connection selected') ->body('Create or select a provider connection first.') ->warning() ->send(); return; } $result = app(ProviderOperationStartGate::class)->start( tenant: $tenant, connection: $connection, operationType: 'provider.connection.check', dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void { ProviderConnectionHealthCheckJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $connection->getKey(), operationRun: $run, ); }, initiator: $user, extraContext: [ 'wizard' => [ 'flow' => 'managed_tenant_onboarding', 'step' => 'verification', ], ], ); if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [ 'provider_connection_id' => (int) $connection->getKey(), 'verification_operation_run_id' => (int) $result->run->getKey(), ]); $this->onboardingSession->current_step = 'verify'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } $auditStatus = match ($result->status) { 'started' => 'success', 'deduped' => 'deduped', 'scope_busy' => 'blocked', default => 'success', }; app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingVerificationStart->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'operation_run_id' => (int) $result->run->getKey(), 'result' => (string) $result->status, ], ], actor: $user, status: $auditStatus, resourceType: 'operation_run', resourceId: (string) $result->run->getKey(), ); if ($result->status === 'scope_busy') { Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') ->warning() ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ]) ->send(); return; } Notification::make() ->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started') ->success() ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())), ]) ->send(); } public function refreshVerificationStatus(): void { if ($this->managedTenant instanceof Tenant) { $this->managedTenant->refresh(); } if ($this->onboardingSession instanceof TenantOnboardingSession) { $this->onboardingSession->refresh(); } Notification::make() ->title('Verification refreshed') ->success() ->send(); } /** * @param array $operationTypes */ public function startBootstrap(array $operationTypes): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMember($user); if (! $this->managedTenant instanceof Tenant) { abort(404); } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } if (! $this->verificationHasSucceeded()) { Notification::make() ->title('Verification required') ->body('Run verification successfully before starting bootstrap actions.') ->warning() ->send(); return; } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('No provider connection selected') ->warning() ->send(); return; } $registry = app(ProviderOperationRegistry::class); $types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== ''))); $types = array_values(array_filter( $types, static fn (string $type): bool => $type !== 'provider.connection.check' && $registry->isAllowed($type), )); foreach ($types as $operationType) { $capability = $this->resolveBootstrapCapability($operationType); if ($capability === null) { abort(422); } if (! $user->can($capability, $this->workspace)) { abort(403); } } if (empty($types)) { Notification::make() ->title('No bootstrap actions selected') ->warning() ->send(); return; } /** @var array{status: 'started', runs: array}|array{status: 'scope_busy', run: OperationRun} $result */ $result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array { $lockedConnection = ProviderConnection::query() ->whereKey($connection->getKey()) ->lockForUpdate() ->firstOrFail(); $activeRun = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->active() ->where('context->provider_connection_id', (int) $lockedConnection->getKey()) ->orderByDesc('id') ->first(); if ($activeRun instanceof OperationRun) { return [ 'status' => 'scope_busy', 'run' => $activeRun, ]; } $runsService = app(OperationRunService::class); $bootstrapRuns = []; foreach ($types as $operationType) { $definition = $registry->get($operationType); $context = [ 'wizard' => [ 'flow' => 'managed_tenant_onboarding', 'step' => 'bootstrap', ], 'provider' => $lockedConnection->provider, 'module' => $definition['module'], 'provider_connection_id' => (int) $lockedConnection->getKey(), 'target_scope' => [ 'entra_tenant_id' => $lockedConnection->entra_tenant_id, ], ]; $run = $runsService->ensureRunWithIdentity( tenant: $tenant, type: $operationType, identityInputs: [ 'provider_connection_id' => (int) $lockedConnection->getKey(), ], context: $context, initiator: $user, ); if ($run->wasRecentlyCreated) { $this->dispatchBootstrapJob( operationType: $operationType, tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $lockedConnection->getKey(), run: $run, ); } $bootstrapRuns[$operationType] = (int) $run->getKey(); } return [ 'status' => 'started', 'runs' => $bootstrapRuns, ]; }); if ($result['status'] === 'scope_busy') { Notification::make() ->title('Another operation is already running') ->body('Please wait for the active run to finish.') ->warning() ->actions([ Action::make('view_run') ->label('View run') ->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())), ]) ->send(); return; } $bootstrapRuns = $result['runs']; if ($this->onboardingSession instanceof TenantOnboardingSession) { $state = $this->onboardingSession->state ?? []; $existing = $state['bootstrap_operation_runs'] ?? []; $existing = is_array($existing) ? $existing : []; $state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns); $state['bootstrap_operation_types'] = $types; $this->onboardingSession->state = $state; $this->onboardingSession->current_step = 'bootstrap'; $this->onboardingSession->updated_by_user_id = (int) $user->getKey(); $this->onboardingSession->save(); } Notification::make() ->title('Bootstrap started') ->success() ->send(); } private function dispatchBootstrapJob( string $operationType, int $tenantId, int $userId, int $providerConnectionId, OperationRun $run, ): void { match ($operationType) { 'inventory.sync' => ProviderInventorySyncJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, operationRun: $run, ), 'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch( tenantId: $tenantId, userId: $userId, providerConnectionId: $providerConnectionId, operationRun: $run, ), default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"), }; } private function resolveBootstrapCapability(string $operationType): ?string { return match ($operationType) { 'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC, 'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC, default => null, }; } private function canStartAnyBootstrap(): bool { return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC) || $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC) || $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP); } private function currentUserCan(string $capability): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } return $user->can($capability, $this->workspace); } private function tenantlessOperationRunUrl(int $runId): string { return OperationRunLinks::tenantlessView($runId); } public function verificationSucceeded(): bool { return $this->verificationHasSucceeded(); } private function canCompleteOnboarding(): bool { if (! $this->managedTenant instanceof Tenant) { return false; } if (! $this->resolveSelectedProviderConnection($this->managedTenant)) { return false; } if ($this->verificationHasSucceeded()) { return true; } if (! (bool) ($this->data['override_blocked'] ?? false)) { return false; } if ($this->verificationStatus() !== 'blocked') { return false; } return trim((string) ($this->data['override_reason'] ?? '')) !== ''; } public function completeOnboarding(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE); if (! $this->managedTenant instanceof Tenant) { abort(404); } if (! $this->onboardingSession instanceof TenantOnboardingSession) { abort(404); } $run = $this->verificationRun(); $verificationSucceeded = $run instanceof OperationRun && $run->status === OperationRunStatus::Completed->value && $run->outcome === OperationRunOutcome::Succeeded->value; $verificationBlocked = $run instanceof OperationRun && $run->status === OperationRunStatus::Completed->value && $run->outcome === OperationRunOutcome::Failed->value; if (! $verificationSucceeded) { $overrideBlocked = (bool) ($this->data['override_blocked'] ?? false); if (! $overrideBlocked) { Notification::make() ->title('Verification required') ->body('Complete verification successfully before finishing onboarding.') ->warning() ->send(); return; } if (! $verificationBlocked) { throw ValidationException::withMessages([ 'data.override_blocked' => 'Verification override is only allowed when verification is blocked.', ]); } $overrideReason = trim((string) ($this->data['override_reason'] ?? '')); if ($overrideReason === '') { throw ValidationException::withMessages([ 'data.override_reason' => 'Override reason is required.', ]); } } $tenant = $this->managedTenant->fresh(); if (! $tenant instanceof Tenant) { abort(404); } $connection = $this->resolveSelectedProviderConnection($tenant); if (! $connection instanceof ProviderConnection) { Notification::make() ->title('Provider connection required') ->body('Create or select a provider connection before finishing onboarding.') ->warning() ->send(); return; } $overrideBlocked = (bool) ($this->data['override_blocked'] ?? false); $overrideReason = trim((string) ($this->data['override_reason'] ?? '')); DB::transaction(function () use ($tenant, $user): void { $tenant->update(['status' => Tenant::STATUS_ACTIVE]); $this->onboardingSession->forceFill([ 'completed_at' => now(), 'current_step' => 'complete', 'updated_by_user_id' => (int) $user->getKey(), ])->save(); }); app(WorkspaceAuditLogger::class)->log( workspace: $this->workspace, action: AuditActionId::ManagedTenantOnboardingActivation->value, context: [ 'metadata' => [ 'workspace_id' => (int) $this->workspace->getKey(), 'tenant_db_id' => (int) $tenant->getKey(), 'provider_connection_id' => (int) $connection->getKey(), 'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null, 'verification_succeeded' => $verificationSucceeded, 'override_blocked' => $overrideBlocked, 'override_reason' => $overrideBlocked ? $overrideReason : null, ], ], actor: $user, status: $overrideBlocked ? 'override' : 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); $this->redirect(TenantDashboard::getUrl(tenant: $tenant)); } private function verificationRun(): ?OperationRun { if (! $this->managedTenant instanceof Tenant) { return null; } if (! $this->onboardingSession instanceof TenantOnboardingSession) { return null; } $runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null; if (! is_int($runId)) { return null; } return OperationRun::query() ->where('tenant_id', (int) $this->managedTenant->getKey()) ->whereKey($runId) ->first(); } private function verificationHasSucceeded(): bool { $run = $this->verificationRun(); if (! $run instanceof OperationRun) { return false; } return $run->status === OperationRunStatus::Completed->value && $run->outcome === OperationRunOutcome::Succeeded->value; } /** * @return array */ private function bootstrapOperationOptions(): array { $registry = app(ProviderOperationRegistry::class); return collect($registry->all()) ->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check') ->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)]) ->all(); } private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int { $id = ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->where('is_default', true) ->orderByDesc('id') ->value('id'); if (is_int($id)) { return $id; } $fallback = ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->orderByDesc('id') ->value('id'); return is_int($fallback) ? $fallback : null; } private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderConnection { $providerConnectionId = $this->selectedProviderConnectionId; if (! is_int($providerConnectionId) && $this->onboardingSession instanceof TenantOnboardingSession) { $candidate = $this->onboardingSession->state['provider_connection_id'] ?? null; $providerConnectionId = is_int($candidate) ? $candidate : null; } if (! is_int($providerConnectionId)) { $providerConnectionId = $this->resolveDefaultProviderConnectionId($tenant); } if (! is_int($providerConnectionId)) { return null; } return ProviderConnection::query() ->where('workspace_id', (int) $this->workspace->getKey()) ->where('tenant_id', (int) $tenant->getKey()) ->whereKey($providerConnectionId) ->first(); } }