TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php

3269 lines
132 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Support\VerificationReportChangeIndicator;
use App\Filament\Support\VerificationReportViewer;
use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Jobs\ProviderInventorySyncJob;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\VerificationCheckAcknowledgement;
use App\Models\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\TenantMembershipManager;
use App\Services\Intune\AuditLogger;
use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Services\Providers\ProviderOperationRegistry;
use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Verification\VerificationCheckAcknowledgementService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Onboarding\OnboardingDraftStage;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use App\Support\Verification\VerificationCheckStatus;
use App\Support\Verification\VerificationReportOverall;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ViewField;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions as SchemaActions;
use Filament\Schemas\Components\Callout;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\UnorderedList;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\Width;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ManagedTenantOnboardingWizard extends Page
{
use AuthorizesRequests;
protected static string $layout = 'filament-panels::components.layout.simple';
protected Width|string|null $maxContentWidth = Width::ScreenExtraLarge;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $title = 'Managed tenant onboarding';
protected static ?string $slug = 'onboarding';
/**
* Disable the simple-layout topbar to prevent lazy-loaded
* DatabaseNotifications from triggering Livewire update 404s
* on this workspace-scoped route.
*/
protected function getLayoutData(): array
{
return [
'hasTopbar' => false,
];
}
public Workspace $workspace;
public ?Tenant $managedTenant = null;
public ?TenantOnboardingSession $onboardingSession = null;
public ?int $selectedProviderConnectionId = null;
public bool $showDraftPicker = false;
public bool $showStartState = false;
/**
* Filament schema state.
*
* @var array<string, mixed>
*/
public array $data = [];
/**
* @var array<int, string>
*/
public array $selectedBootstrapOperationTypes = [];
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
$actions = [];
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$actions[] = Action::make('back_to_onboarding_landing')
->label('All onboarding drafts')
->color('gray')
->url(route('admin.onboarding'));
}
if ($this->onboardingSession instanceof TenantOnboardingSession && $this->onboardingSession->isResumable()) {
$actions[] = Action::make('cancel_onboarding_draft')
->label('Cancel draft')
->color('danger')
->requiresConfirmation()
->modalHeading('Cancel onboarding draft')
->modalDescription('This draft will become non-resumable. Confirm only if you intend to stop this onboarding flow.')
->visible(fn (): bool => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL))
->action(fn () => $this->cancelOnboardingDraft());
}
return $actions;
}
public function mount(TenantOnboardingSession|int|string|null $onboardingDraft = null): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
if ($onboardingDraft !== null) {
abort(404);
}
$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->authorize('viewAny', TenantOnboardingSession::class);
if ($onboardingDraft !== null) {
$this->loadOnboardingDraft($user, $onboardingDraft);
$this->initializeWizardData();
return;
}
if ($this->resolveLandingState($user)) {
return;
}
$this->showStartState = true;
$this->initializeWizardData();
}
public function content(Schema $schema): Schema
{
if ($this->showDraftPicker) {
return $schema->schema($this->draftPickerSchema());
}
if ($this->showsNonResumableSummary()) {
return $schema->schema($this->nonResumableSummarySchema());
}
return $schema
->statePath('data')
->schema([
...$this->resumeContextSchema(),
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()
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
->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')
->hintActions([
Action::make('edit_selected_connection')
->label('Edit selected connection')
->icon('heroicon-m-pencil-square')
->color('gray')
->slideOver()
->modalHeading('Edit provider connection')
->modalDescription('Changes apply to this workspace connection.')
->modalSubmitActionLabel('Save changes')
->closeModalByClickingAway(false)
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'
&& is_numeric($get('provider_connection_id')))
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE))
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE)
? null
: 'You don\'t have permission to edit connections.')
->fillForm(function (Get $get): array {
$recordId = $get('provider_connection_id');
if (! is_numeric($recordId)) {
return [];
}
return $this->inlineEditSelectedConnectionFill((int) $recordId);
})
->form([
TextInput::make('display_name')
->label('Connection name')
->required()
->maxLength(255),
TextInput::make('entra_tenant_id')
->label('Directory (tenant) ID')
->disabled()
->dehydrated(false),
Toggle::make('uses_dedicated_override')
->label('Dedicated override')
->helperText('Explicit exception path for a customer-managed app registration.')
->visible(fn (): bool => $this->canManageDedicatedOverride())
->live(),
TextInput::make('client_id')
->label('Dedicated app (client) ID')
->required(fn (Get $get): bool => (bool) $get('uses_dedicated_override'))
->maxLength(255)
->helperText('If you change the dedicated App (client) ID, enter the matching new client secret below.')
->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')),
TextInput::make('client_secret')
->label('New dedicated client secret')
->password()
->revealable(false)
->maxLength(255)
->helperText('Required when enabling dedicated mode or changing the dedicated App (client) ID. The existing secret is never shown.')
->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')),
])
->action(function (array $data, Get $get): void {
$recordId = $get('provider_connection_id');
if (! is_numeric($recordId)) {
abort(404);
}
$this->updateSelectedProviderConnectionInline((int) $recordId, $data);
}),
]),
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.connection_type')
->label('Connection type')
->default('Platform connection')
->disabled()
->dehydrated(false)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
->helperText('Managed centrally by platform. Grant admin consent after creating the connection.'),
TextInput::make('new_connection.platform_app_id')
->label('Platform app ID')
->default(fn (): string => $this->platformAppClientId())
->disabled()
->dehydrated(false)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
Toggle::make('new_connection.uses_dedicated_override')
->label('Dedicated override')
->helperText('Explicit exception path for a customer-managed app registration.')
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && $this->canManageDedicatedOverride())
->live(),
TextInput::make('new_connection.client_id')
->label('Dedicated app (client) ID')
->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override'))
->maxLength(255)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')),
TextInput::make('new_connection.client_secret')
->label('Dedicated client secret')
->password()
->revealable(false)
->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override'))
->maxLength(255)
->helperText('Required only for the dedicated override path. The secret is never shown again after save.')
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')),
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'] ?? ''),
'connection_type' => (bool) ($new['uses_dedicated_override'] ?? false)
? ProviderConnectionType::Dedicated->value
: ProviderConnectionType::Platform->value,
'client_id' => (string) ($new['client_id'] ?? ''),
'client_secret' => (string) ($new['client_secret'] ?? ''),
'is_default' => (bool) ($new['is_default'] ?? true),
]);
} 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('Connection updated — re-run verification to refresh results.')
->visible(fn (): bool => $this->connectionRecentlyUpdated()),
Text::make('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.')
->visible(fn (): bool => $this->verificationRunIsStaleForSelectedConnection()),
Text::make('Verification is in progress. Use “Refresh results” 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 && ! $this->verificationRunIsActive())
->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 && $this->verificationStatus() === 'in_progress')
->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->verificationCanProceed()) {
Notification::make()
->title('Verification required')
->body('Complete verification for the selected provider connection 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('Review configuration and activate the tenant.')
->schema([
Section::make('Review & Activate')
->description('Review the onboarding summary before activating this tenant.')
->schema([
Section::make('Onboarding summary')
->compact()
->columns(2)
->schema([
Text::make('Tenant')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryTenantLine())
->weight(FontWeight::SemiBold),
Text::make('Provider connection')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryConnectionSummary())
->weight(FontWeight::SemiBold),
Text::make('Verification')
->color('gray'),
Text::make(fn (): string => $this->verificationStatusLabel().' — '.$this->completionSummaryVerificationDetail())
->badge()
->color(fn (): string => $this->verificationStatusColor()),
Text::make('Bootstrap')
->color('gray'),
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
->badge()
->color(fn (): string => $this->completionSummaryBootstrapColor()),
]),
Callout::make('After activation')
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
->info()
->footer([
UnorderedList::make([
'Tenant status will be set to Active.',
'Backup, inventory, and compliance operations become available.',
'The provider connection will be used for all Graph API calls.',
]),
]),
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('Activate tenant')
->color('success')
->requiresConfirmation()
->modalHeading('Activate tenant')
->modalDescription(fn (): string => $this->managedTenant instanceof Tenant
? sprintf('Are you sure you want to activate "%s"? This will make the tenant operational.', $this->managedTenant->name)
: 'Are you sure you want to activate this tenant?')
->modalSubmitActionLabel('Yes, activate')
->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 resolveLandingState(User $user): bool
{
$drafts = $this->availableDraftsFor($user);
if ($drafts->count() === 1) {
$draft = $drafts->first();
if ($draft instanceof TenantOnboardingSession) {
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft]));
return true;
}
}
if ($drafts->count() > 1) {
$this->showDraftPicker = true;
}
return false;
}
private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|string $onboardingDraft): void
{
$draft = app(OnboardingDraftResolver::class)->resolve($onboardingDraft, $user, $this->workspace);
$this->showDraftPicker = false;
$this->showStartState = false;
$this->onboardingSession = $draft;
$tenant = $draft->tenant;
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
$this->managedTenant = $tenant;
}
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
$this->selectedProviderConnectionId = is_int($providerConnectionId)
? $providerConnectionId
: ($tenant instanceof Tenant ? $this->resolveDefaultProviderConnectionId($tenant) : null);
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
: [];
}
/**
* @return Collection<int, TenantOnboardingSession>
*/
private function availableDraftsFor(User $user): Collection
{
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $this->workspace);
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function draftPickerSchema(): array
{
$drafts = $this->currentUser() instanceof User
? $this->availableDraftsFor($this->currentUser())
: collect();
$components = [
Callout::make('Multiple onboarding drafts are available.')
->description('Select a draft to resume or start a new onboarding flow explicitly.')
->warning(),
SchemaActions::make([
Action::make('start_new_onboarding_draft')
->label('Start new onboarding')
->color('primary')
->action(fn () => $this->startNewOnboardingDraft()),
])->key('draft_picker_start_actions'),
];
foreach ($drafts as $draft) {
$components[] = Section::make($this->draftTitle($draft))
->description($this->draftDescription($draft))
->compact()
->columns(2)
->schema([
Text::make('Current stage')
->color('gray'),
Text::make(fn (): string => $this->draftStageLabel($draft))
->badge()
->color(fn (): string => $this->draftStageColor($draft)),
Text::make('Status')
->color('gray'),
Text::make(fn (): string => $draft->status()->label())
->badge()
->color(fn (): string => $this->draftStatusColor($draft)),
Text::make('Started by')
->color('gray'),
Text::make(fn (): string => $draft->startedByUser?->name ?? 'Unknown'),
Text::make('Last updated by')
->color('gray'),
Text::make(fn (): string => $draft->updatedByUser?->name ?? 'Unknown'),
Text::make('Last updated')
->color('gray'),
Text::make(fn (): string => $draft->updated_at?->diffForHumans() ?? '—'),
Text::make('Draft age')
->color('gray'),
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
SchemaActions::make([
Action::make('resume_draft_'.$draft->getKey())
->label('Resume onboarding draft')
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), true)),
Action::make('view_draft_'.$draft->getKey())
->label('View summary')
->color('gray')
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), false)),
])->key('draft_picker_actions_'.$draft->getKey()),
]);
}
return $components;
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function resumeContextSchema(): array
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
return [
Section::make('Onboarding draft')
->compact()
->columns(2)
->schema([
Text::make('Tenant')
->color('gray'),
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
->weight(FontWeight::SemiBold),
Text::make('Current stage')
->color('gray'),
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
->badge()
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
Text::make('Started by')
->color('gray'),
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
Text::make('Last updated by')
->color('gray'),
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
]),
];
}
/**
* @return array<int, \Filament\Schemas\Components\Component>
*/
private function nonResumableSummarySchema(): array
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return [];
}
$statusLabel = $this->onboardingSession->status()->label();
return [
Callout::make("This onboarding draft is {$statusLabel}.")
->description('Completed and cancelled drafts remain viewable, but they cannot return to editable wizard mode.')
->warning(),
...$this->resumeContextSchema(),
Section::make('Draft summary')
->compact()
->columns(2)
->schema([
Text::make('Status')
->color('gray'),
Text::make(fn (): string => $statusLabel)
->badge()
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
Text::make('Primary domain')
->color('gray'),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
Text::make('Environment')
->color('gray'),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
Text::make('Notes')
->color('gray'),
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
]),
SchemaActions::make([
Action::make('return_to_onboarding_landing')
->label('Return to onboarding')
->color('gray')
->url(route('admin.onboarding')),
]),
];
}
private function startNewOnboardingDraft(): void
{
$this->showDraftPicker = false;
$this->showStartState = true;
$this->managedTenant = null;
$this->onboardingSession = null;
$this->selectedProviderConnectionId = null;
$this->selectedBootstrapOperationTypes = [];
$this->data = [];
$this->initializeWizardData();
}
private function resumeOnboardingDraft(int $draftId, bool $logSelection): void
{
$user = $this->currentUser();
if (! $user instanceof User) {
abort(403);
}
$draft = app(OnboardingDraftResolver::class)->resolve($draftId, $user, $this->workspace);
if ($logSelection) {
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingDraftSelected->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'onboarding_session_id' => (int) $draft->getKey(),
'tenant_db_id' => $draft->tenant_id !== null ? (int) $draft->tenant_id : null,
],
],
actor: $user,
status: 'success',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $draft->getKey(),
);
}
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft]));
}
private function cancelOnboardingDraft(): void
{
$user = $this->currentUser();
if (! $user instanceof User) {
abort(403);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
abort(404);
}
$this->authorize('cancel', $this->onboardingSession);
if (! $this->onboardingSession->isResumable()) {
Notification::make()
->title('Draft is not resumable')
->warning()
->send();
return;
}
$this->onboardingSession->forceFill([
'current_step' => 'cancelled',
'cancelled_at' => now(),
'updated_by_user_id' => (int) $user->getKey(),
])->save();
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingCancelled->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
],
],
actor: $user,
status: 'success',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(),
);
$this->onboardingSession->refresh();
Notification::make()
->title('Onboarding draft cancelled')
->success()
->send();
}
private function showsNonResumableSummary(): bool
{
return $this->onboardingSession instanceof TenantOnboardingSession
&& ! $this->onboardingSession->isResumable();
}
private function draftTitle(TenantOnboardingSession $draft): string
{
$state = is_array($draft->state) ? $draft->state : [];
$tenantName = $state['tenant_name'] ?? $draft->tenant?->name ?? null;
$tenantName = is_string($tenantName) && trim($tenantName) !== '' ? trim($tenantName) : 'Unidentified tenant';
$entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id;
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
return sprintf('%s (%s)', $tenantName, trim($entraTenantId));
}
return $tenantName;
}
private function draftDescription(TenantOnboardingSession $draft): string
{
$state = is_array($draft->state) ? $draft->state : [];
$environment = $state['environment'] ?? null;
$primaryDomain = $state['primary_domain'] ?? null;
return collect([
is_string($environment) && $environment !== '' ? ucfirst($environment) : null,
is_string($primaryDomain) && $primaryDomain !== '' ? $primaryDomain : null,
])->filter()->implode(' · ');
}
private function draftStageLabel(TenantOnboardingSession $draft): string
{
return app(OnboardingDraftStageResolver::class)->resolve($draft)->label();
}
private function draftStageColor(TenantOnboardingSession $draft): string
{
return match (app(OnboardingDraftStageResolver::class)->resolve($draft)) {
OnboardingDraftStage::Identify => 'gray',
OnboardingDraftStage::ConnectProvider => 'info',
OnboardingDraftStage::VerifyAccess => 'warning',
OnboardingDraftStage::Bootstrap => 'info',
OnboardingDraftStage::Review => 'success',
OnboardingDraftStage::Completed => 'success',
OnboardingDraftStage::Cancelled => 'danger',
};
}
private function draftStatusColor(TenantOnboardingSession $draft): string
{
return match ($draft->status()->value) {
'draft' => 'info',
'completed' => 'success',
'cancelled' => 'danger',
default => 'gray',
};
}
private function currentUser(): ?User
{
$user = auth()->user();
return $user instanceof User ? $user : null;
}
private function initializeWizardData(): void
{
// Ensure all entangled schema state paths exist at render time.
// Livewire v4 can throw when entangling to missing nested array keys.
$this->data['notes'] ??= '';
$this->data['override_blocked'] ??= false;
$this->data['override_reason'] ??= '';
$this->data['new_connection'] ??= [];
if (! array_key_exists('connection_mode', $this->data)) {
$this->data['connection_mode'] = 'existing';
}
if (is_array($this->data['new_connection'])) {
$this->data['new_connection']['connection_type'] ??= 'Platform connection';
$this->data['new_connection']['platform_app_id'] ??= $this->platformAppClientId();
$this->data['new_connection']['uses_dedicated_override'] ??= false;
$this->data['new_connection']['is_default'] ??= true;
}
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
{
return app(OnboardingDraftStageResolver::class)
->resolve($this->onboardingSession)
->wizardStep();
}
/**
* @return array<int, string>
*/
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 (! $this->verificationRunMatchesSelectedConnection($run)) {
return 'needs_attention';
}
if ($run->status !== OperationRunStatus::Completed->value) {
return 'in_progress';
}
return match ($this->verificationReportOverall()) {
VerificationReportOverall::Blocked->value => 'blocked',
VerificationReportOverall::NeedsAttention->value => 'needs_attention',
VerificationReportOverall::Ready->value => 'ready',
VerificationReportOverall::Running->value => 'in_progress',
default => $this->verificationStatusFromRunOutcome($run),
};
}
private function verificationStatusFromRunOutcome(OperationRun $run): string
{
if ($run->outcome === OperationRunOutcome::Blocked->value) {
return 'blocked';
}
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', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
return 'blocked';
}
}
return 'needs_attention';
}
private function verificationReportOverall(): ?string
{
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return null;
}
$report = VerificationReportViewer::report($run);
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : null;
$overall = $summary['overall'] ?? null;
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
return null;
}
return $overall;
}
/**
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}|null
*/
private function verificationReportCounts(): ?array
{
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return null;
}
$report = VerificationReportViewer::report($run);
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : null;
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : null;
if (! is_array($counts)) {
return null;
}
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
return null;
}
}
return [
'total' => $counts['total'],
'pass' => $counts['pass'],
'fail' => $counts['fail'],
'warn' => $counts['warn'],
'skip' => $counts['skip'],
'running' => $counts['running'],
];
}
private function verificationRunIsActive(): bool
{
$run = $this->verificationRun();
return $run instanceof OperationRun
&& $run->status !== OperationRunStatus::Completed->value;
}
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<string, mixed>|null,
* runUrl: string|null,
* report: array<string, mixed>|null,
* fingerprint: string|null,
* changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null,
* previousRunUrl: string|null,
* canAcknowledge: bool,
* acknowledgements: array<string, array{
* check_key: string,
* ack_reason: string,
* acknowledged_at: string|null,
* expires_at: string|null,
* acknowledged_by: array{id: int, name: string}|null
* }>
* }
*/
private function verificationReportViewData(): array
{
$run = $this->verificationRun();
$runUrl = $this->verificationRunUrl();
if (! $run instanceof OperationRun) {
return [
'run' => null,
'runUrl' => $runUrl,
'report' => null,
'fingerprint' => null,
'changeIndicator' => null,
'previousRunUrl' => null,
'canAcknowledge' => false,
'acknowledgements' => [],
];
}
$report = VerificationReportViewer::report($run);
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
$previousRunUrl = $changeIndicator === null
? null
: $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']);
$user = auth()->user();
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant
? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)
: false;
$acknowledgements = VerificationCheckAcknowledgement::query()
->where('tenant_id', (int) $run->tenant_id)
->where('workspace_id', (int) $run->workspace_id)
->where('operation_run_id', (int) $run->getKey())
->with('acknowledgedByUser')
->get()
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
$user = $ack->acknowledgedByUser;
return [
(string) $ack->check_key => [
'check_key' => (string) $ack->check_key,
'ack_reason' => (string) $ack->ack_reason,
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
'expires_at' => $ack->expires_at?->toJSON(),
'acknowledged_by' => $user instanceof User
? [
'id' => (int) $user->getKey(),
'name' => (string) $user->name,
]
: null,
],
];
})
->all();
$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 : [];
$verificationReport = VerificationReportViewer::report($run);
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,
'report' => $report,
'fingerprint' => $fingerprint,
'changeIndicator' => $changeIndicator,
'previousRunUrl' => $previousRunUrl,
'canAcknowledge' => $canAcknowledge,
'acknowledgements' => $acknowledgements,
];
}
public function acknowledgeVerificationCheckAction(): Action
{
return Action::make('acknowledgeVerificationCheck')
->label('Acknowledge')
->color('gray')
->requiresConfirmation()
->modalHeading('Acknowledge issue')
->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.')
->form([
Textarea::make('ack_reason')
->label('Reason')
->required()
->maxLength(160)
->rows(3),
TextInput::make('expires_at')
->label('Expiry (optional)')
->helperText('Optional timestamp (informational only).')
->maxLength(64),
])
->action(function (array $data, array $arguments): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
throw new NotFoundHttpException;
}
$checkKey = (string) ($arguments['check_key'] ?? '');
$ackReason = (string) ($data['ack_reason'] ?? '');
$expiresAt = $data['expires_at'] ?? null;
$expiresAt = is_string($expiresAt) ? $expiresAt : null;
try {
app(VerificationCheckAcknowledgementService::class)->acknowledge(
tenant: $tenant,
run: $run,
checkKey: $checkKey,
ackReason: $ackReason,
expiresAt: $expiresAt,
actor: $user,
);
} catch (InvalidArgumentException $e) {
Notification::make()
->title('Unable to acknowledge')
->body($e->getMessage())
->danger()
->send();
return;
}
Notification::make()
->title('Issue acknowledged')
->success()
->send();
})
->visible(function (array $arguments): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (! $this->managedTenant instanceof Tenant) {
return false;
}
if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) {
return false;
}
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return false;
}
$checkKey = trim((string) ($arguments['check_key'] ?? ''));
if ($checkKey === '') {
return false;
}
$ackExists = VerificationCheckAcknowledgement::query()
->where('operation_run_id', (int) $run->getKey())
->where('check_key', $checkKey)
->exists();
if ($ackExists) {
return false;
}
$report = VerificationReportViewer::report($run);
if (! is_array($report)) {
return false;
}
$checks = $report['checks'] ?? null;
$checks = is_array($checks) ? $checks : [];
foreach ($checks as $check) {
if (! is_array($check)) {
continue;
}
if (($check['key'] ?? null) !== $checkKey) {
continue;
}
$status = $check['status'] ?? null;
return is_string($status) && in_array($status, [
VerificationCheckStatus::Fail->value,
VerificationCheckStatus::Warn->value,
], true);
}
return false;
});
}
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();
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingDraftUpdated->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'current_step' => $step,
],
],
actor: $user,
status: 'success',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(),
);
}
private function authorizeWorkspaceMutation(User $user, string $capability): void
{
$this->authorizeWorkspaceMember($user);
if (! $user->can($capability, $this->workspace)) {
abort(403);
}
}
private function authorizeEditableDraft(User $user): void
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
$this->authorize('update', $this->onboardingSession);
if (! $this->onboardingSession->isResumable()) {
abort(404);
}
}
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;
$notificationTitle = 'Onboarding draft ready';
$notificationBody = null;
DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes, &$notificationTitle, &$notificationBody): void {
$auditLogger = app(WorkspaceAuditLogger::class);
$membershipManager = app(TenantMembershipManager::class);
$currentDraftId = $this->onboardingSession?->getKey();
$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)
->resumable()
->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();
}
$didResumeExistingDraft = ! $sessionWasCreated
&& ($currentDraftId === null || (int) $currentDraftId !== (int) $session->getKey());
$notificationTitle = $didResumeExistingDraft
? 'Existing onboarding draft resumed'
: 'Onboarding draft ready';
$notificationBody = $didResumeExistingDraft
? 'A resumable draft already exists for this tenant. TenantAtlas reopened it instead of creating a duplicate.'
: null;
$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 = Notification::make()
->title($notificationTitle)
->success();
if (is_string($notificationBody) && $notificationBody !== '') {
$notification->body($notificationBody);
}
$notification->send();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $this->onboardingSession]));
}
}
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);
$this->authorizeEditableDraft($user);
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);
}
$previousProviderConnectionId = $this->selectedProviderConnectionId;
$this->selectedProviderConnectionId = (int) $connection->getKey();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
state: array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]),
previousProviderConnectionId: $previousProviderConnectionId,
newProviderConnectionId: (int) $connection->getKey(),
);
$this->onboardingSession->current_step = 'connection';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $this->managedTenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
],
actor: $user,
status: 'success',
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
);
Notification::make()
->title('Provider connection selected')
->success()
->send();
}
/**
* @param array{display_name: string, connection_type?: 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);
$this->authorizeEditableDraft($user);
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'] ?? ''));
$requestedConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? '')));
$clientId = trim((string) ($data['client_id'] ?? ''));
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
$makeDefault = (bool) ($data['is_default'] ?? false);
$usesDedicatedCredential = $requestedConnectionType === ProviderConnectionType::Dedicated
|| $clientId !== ''
|| $clientSecret !== '';
if ($displayName === '') {
abort(422);
}
if ($usesDedicatedCredential) {
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
}
if ($usesDedicatedCredential && ($clientId === '' || $clientSecret === '')) {
abort(422);
}
$wasExistingConnection = false;
$previousConnectionType = null;
/** @var ProviderConnection $connection */
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection {
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
$connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft')
->where('entra_tenant_id', (string) $tenant->tenant_id)
->first();
if (! $connection instanceof ProviderConnection) {
$connection = ProviderConnection::query()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => $displayName,
'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null,
'consent_last_checked_at' => null,
'consent_error_code' => null,
'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false,
'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'last_error_message' => null,
]);
} else {
$wasExistingConnection = true;
$previousConnectionType = $connection->connection_type instanceof ProviderConnectionType
? $connection->connection_type
: ProviderConnectionType::Platform;
$connection->forceFill([
'workspace_id' => (int) $tenant->workspace_id,
'display_name' => $displayName,
])->save();
}
if ($usesDedicatedCredential) {
$connection = app(ProviderConnectionMutationService::class)->enableDedicatedOverride(
connection: $connection,
clientId: $clientId,
clientSecret: $clientSecret,
);
}
if ($makeDefault) {
$connection->makeDefault();
}
return $connection;
});
$auditLogger = app(AuditLogger::class);
$actorId = (int) $user->getKey();
$actorEmail = (string) $user->email;
$actorName = (string) $user->name;
if (! $wasExistingConnection) {
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.created',
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'connection_type' => $connection->connection_type->value,
'source' => 'managed_tenant_onboarding_wizard.create',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
if ($previousConnectionType instanceof ProviderConnectionType && $previousConnectionType !== $connection->connection_type) {
$auditLogger->log(
tenant: $tenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'from_connection_type' => $previousConnectionType->value,
'to_connection_type' => $connection->connection_type->value,
'source' => 'managed_tenant_onboarding_wizard.create',
],
],
actorId: $actorId,
actorEmail: $actorEmail,
actorName: $actorName,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
$this->selectedProviderConnectionId = (int) $connection->getKey();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$previousProviderConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
$previousProviderConnectionId = is_int($previousProviderConnectionId)
? $previousProviderConnectionId
: (is_numeric($previousProviderConnectionId) ? (int) $previousProviderConnectionId : null);
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
state: array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]),
previousProviderConnectionId: $previousProviderConnectionId,
newProviderConnectionId: (int) $connection->getKey(),
);
$this->onboardingSession->current_step = 'connection';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $this->managedTenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'onboarding_session_id' => $this->onboardingSession?->getKey(),
],
],
actor: $user,
status: 'success',
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
);
Notification::make()
->title('Provider connection created')
->success()
->send();
}
private function platformAppClientId(): string
{
$clientId = trim((string) config('graph.client_id'));
return $clientId !== '' ? $clientId : 'Platform app not configured';
}
private function canManageDedicatedOverride(): bool
{
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
}
public function startVerification(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
$this->authorizeEditableDraft($user);
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(),
'connection_recently_updated' => false,
]);
$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 ($this->onboardingSession instanceof TenantOnboardingSession) {
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingVerificationPersisted->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_run_id' => (int) $result->run->getKey(),
],
],
actor: $user,
status: 'success',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(),
);
}
if ($result->status === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
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;
}
if ($result->status === 'blocked') {
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
? (string) $result->run->context['reason_code']
: 'unknown_error';
$actions = [
Action::make('view_run')
->label('View run')
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
];
$nextSteps = $result->run->context['next_steps'] ?? [];
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
foreach ($nextSteps as $index => $step) {
if (! is_array($step)) {
continue;
}
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
if ($label === '' || $url === '') {
continue;
}
$actions[] = Action::make('next_step_'.$index)
->label($label)
->url($url);
break;
}
Notification::make()
->title('Verification blocked')
->body("Blocked by provider configuration ({$reasonCode}).")
->warning()
->actions($actions)
->send();
return;
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
if ($result->status === 'deduped') {
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
->actions([
Action::make('view_run')
->label('View run')
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
])
->send();
return;
}
OperationUxPresenter::queuedToast((string) $result->run->type)
->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<int, mixed> $operationTypes
*/
public function startBootstrap(array $operationTypes): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMember($user);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $this->verificationCanProceed()) {
Notification::make()
->title('Verification required')
->body('Complete verification for the selected provider connection 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<string, int>, created: array<string, bool>}|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 = [];
$bootstrapCreated = [];
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();
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
}
return [
'status' => 'started',
'runs' => $bootstrapRuns,
'created' => $bootstrapCreated,
];
});
if ($result['status'] === 'scope_busy') {
OpsUxBrowserEvents::dispatchRunEnqueued($this);
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();
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'operation_types' => $types,
'operation_run_ids' => $bootstrapRuns,
],
],
actor: $user,
status: 'success',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(),
);
}
OpsUxBrowserEvents::dispatchRunEnqueued($this);
foreach ($types as $operationType) {
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
$toast = $wasCreated
? OperationUxPresenter::queuedToast($operationType)
: OperationUxPresenter::alreadyQueuedToast($operationType);
if ($runUrl !== null) {
$toast->actions([
Action::make('view_run')
->label('View run')
->url($runUrl),
]);
}
$toast->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 verificationCanProceed(): bool
{
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return false;
}
if ($run->status !== OperationRunStatus::Completed->value) {
return false;
}
if (! $this->verificationRunMatchesSelectedConnection($run)) {
return false;
}
return in_array($this->verificationStatus(), ['ready', 'needs_attention'], true);
}
private function verificationIsBlocked(): bool
{
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return false;
}
if ($run->status !== OperationRunStatus::Completed->value) {
return false;
}
if (! $this->verificationRunMatchesSelectedConnection($run)) {
return false;
}
return $this->verificationStatus() === 'blocked';
}
private function canCompleteOnboarding(): bool
{
if (! $this->managedTenant instanceof Tenant) {
return false;
}
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
return false;
}
if ($this->verificationCanProceed()) {
return true;
}
if (! (bool) ($this->data['override_blocked'] ?? false)) {
return false;
}
if (! $this->verificationIsBlocked()) {
return false;
}
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
}
private function completionSummaryTenantLine(): string
{
if (! $this->managedTenant instanceof Tenant) {
return '—';
}
$name = $this->managedTenant->name ?? '—';
$tenantId = $this->managedTenant->graphTenantId();
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
}
private function completionSummaryConnectionLabel(): string
{
if (! $this->managedTenant instanceof Tenant) {
return '—';
}
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
if (! $connection instanceof ProviderConnection) {
return 'Not configured';
}
$type = $connection->connection_type instanceof ProviderConnectionType
? $connection->connection_type
: ProviderConnectionType::tryFrom((string) $connection->connection_type);
return match ($type) {
ProviderConnectionType::Platform => 'Platform',
ProviderConnectionType::Dedicated => 'Dedicated',
default => (string) ($connection->display_name ?? 'Unknown'),
};
}
private function completionSummaryConnectionDetail(): string
{
if (! $this->managedTenant instanceof Tenant) {
return '';
}
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
if (! $connection instanceof ProviderConnection) {
return '';
}
$consentStatus = $connection->consent_status instanceof ProviderConsentStatus
? $connection->consent_status
: ProviderConsentStatus::tryFrom((string) $connection->consent_status);
$consentLabel = match ($consentStatus) {
ProviderConsentStatus::Granted => 'Consent granted',
ProviderConsentStatus::Failed => 'Consent failed',
ProviderConsentStatus::Required => 'Consent required',
ProviderConsentStatus::Revoked => 'Consent revoked',
default => 'Consent unknown',
};
$parts = [$connection->display_name ?? '', $consentLabel];
return implode(' · ', array_filter($parts, static fn (string $v): bool => $v !== ''));
}
private function completionSummaryConnectionSummary(): string
{
$label = $this->completionSummaryConnectionLabel();
$detail = $this->completionSummaryConnectionDetail();
if ($detail === '') {
return $label;
}
return sprintf('%s - %s', $label, $detail);
}
private function completionSummaryVerificationDetail(): string
{
$counts = $this->verificationReportCounts();
if ($counts === null) {
return 'Not started';
}
return sprintf('%d/%d checks passed', $counts['pass'], $counts['total']);
}
private function completionSummaryBootstrapLabel(): string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'Skipped';
}
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : [];
if ($runs === []) {
return 'Skipped';
}
return 'Started';
}
private function completionSummaryBootstrapDetail(): string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return 'No bootstrap actions selected';
}
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : [];
if ($runs === []) {
return 'No bootstrap actions selected';
}
return sprintf('%d operation run(s) started', count($runs));
}
private function completionSummaryBootstrapSummary(): string
{
return sprintf(
'%s - %s',
$this->completionSummaryBootstrapLabel(),
$this->completionSummaryBootstrapDetail(),
);
}
private function completionSummaryBootstrapColor(): string
{
return $this->completionSummaryBootstrapLabel() === 'Started'
? 'info'
: 'gray';
}
public function completeOnboarding(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
$this->authorizeEditableDraft($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
abort(404);
}
$run = $this->verificationRun();
$verificationSucceeded = $this->verificationHasSucceeded();
$verificationCanProceed = $this->verificationCanProceed();
$verificationBlocked = $this->verificationIsBlocked();
if (! $verificationCanProceed) {
$overrideBlocked = (bool) ($this->data['override_blocked'] ?? false);
if (! $overrideBlocked) {
Notification::make()
->title('Verification required')
->body('Complete verification for the selected provider connection 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(),
'cancelled_at' => null,
'current_step' => 'complete',
'updated_by_user_id' => (int) $user->getKey(),
])->save();
});
if ($overrideBlocked) {
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingActivationOverrideUsed->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
'override_reason' => $overrideReason,
],
],
actor: $user,
status: 'override',
resourceType: 'managed_tenant_onboarding_session',
resourceId: (string) $this->onboardingSession->getKey(),
);
}
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(panel: 'tenant', 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
{
return $this->verificationCanProceed()
&& $this->verificationStatus() === 'ready';
}
private function verificationRunIsStaleForSelectedConnection(): bool
{
$run = $this->verificationRun();
if (! $run instanceof OperationRun) {
return false;
}
return ! $this->verificationRunMatchesSelectedConnection($run);
}
private function verificationRunMatchesSelectedConnection(OperationRun $run): bool
{
$selectedProviderConnectionId = $this->selectedProviderConnectionId;
if ($selectedProviderConnectionId === null && $this->onboardingSession instanceof TenantOnboardingSession) {
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
$selectedProviderConnectionId = is_int($candidate)
? $candidate
: (is_numeric($candidate) ? (int) $candidate : null);
}
if ($selectedProviderConnectionId === null) {
return false;
}
$context = is_array($run->context ?? null) ? $run->context : [];
$runProviderConnectionId = $context['provider_connection_id'] ?? null;
$runProviderConnectionId = is_int($runProviderConnectionId)
? $runProviderConnectionId
: (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null);
if ($runProviderConnectionId === null) {
return false;
}
return $runProviderConnectionId === $selectedProviderConnectionId;
}
/**
* @param array<string, mixed> $state
* @return array<string, mixed>
*/
private function resetDependentOnboardingStateOnConnectionChange(array $state, ?int $previousProviderConnectionId, int $newProviderConnectionId): array
{
if ($previousProviderConnectionId === null) {
return $state;
}
if ($previousProviderConnectionId === $newProviderConnectionId) {
return $state;
}
unset(
$state['verification_operation_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
);
$state['connection_recently_updated'] = true;
return $state;
}
private function connectionRecentlyUpdated(): bool
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return false;
}
return (bool) ($this->onboardingSession->state['connection_recently_updated'] ?? false);
}
/**
* @return array{display_name: string, entra_tenant_id: string, uses_dedicated_override: bool, client_id: string}
*/
private function inlineEditSelectedConnectionFill(int $providerConnectionId): array
{
$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);
}
$connection = ProviderConnection::query()
->with('credential')
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', (int) $this->managedTenant->getKey())
->whereKey($providerConnectionId)
->first();
if (! $connection instanceof ProviderConnection) {
abort(404);
}
$payload = $connection->credential?->payload;
$payload = is_array($payload) ? $payload : [];
return [
'display_name' => (string) $connection->display_name,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'uses_dedicated_override' => $connection->connection_type === ProviderConnectionType::Dedicated,
'client_id' => (string) ($payload['client_id'] ?? ''),
];
}
/**
* @param array{display_name?: mixed, connection_type?: mixed, uses_dedicated_override?: mixed, client_id?: mixed, client_secret?: mixed} $data
*/
public function updateSelectedProviderConnectionInline(int $providerConnectionId, 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);
}
$connection = ProviderConnection::query()
->with('credential')
->where('workspace_id', (int) $this->workspace->getKey())
->where('tenant_id', (int) $this->managedTenant->getKey())
->whereKey($providerConnectionId)
->first();
if (! $connection instanceof ProviderConnection) {
abort(404);
}
$displayName = trim((string) ($data['display_name'] ?? ''));
$explicitConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? '')));
$requestedDedicatedOverride = (bool) ($data['uses_dedicated_override'] ?? false);
$clientId = trim((string) ($data['client_id'] ?? ''));
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
$existingType = $connection->connection_type instanceof ProviderConnectionType
? $connection->connection_type
: ProviderConnectionType::Platform;
if ($displayName === '') {
throw ValidationException::withMessages([
'display_name' => 'Connection name is required.',
]);
}
$existingPayload = $connection->credential?->payload;
$existingPayload = is_array($existingPayload) ? $existingPayload : [];
$existingClientId = trim((string) ($existingPayload['client_id'] ?? ''));
$targetType = $explicitConnectionType
?? (($requestedDedicatedOverride || $clientId !== '' || $clientSecret !== '' || $existingType === ProviderConnectionType::Dedicated)
? ProviderConnectionType::Dedicated
: ProviderConnectionType::Platform);
$changedFields = [];
if ($displayName !== (string) $connection->display_name) {
$changedFields[] = 'display_name';
}
if ($targetType !== $existingType) {
$changedFields[] = 'connection_type';
}
if ($targetType === ProviderConnectionType::Dedicated) {
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
if ($clientId === '') {
throw ValidationException::withMessages([
'client_id' => 'Dedicated App (client) ID is required.',
]);
}
if ($clientId !== $existingClientId) {
$changedFields[] = 'client_id';
}
if (($existingType !== ProviderConnectionType::Dedicated || $clientId !== $existingClientId) && $clientSecret === '') {
throw ValidationException::withMessages([
'client_secret' => 'Enter a dedicated client secret when enabling dedicated mode or changing the App (client) ID.',
]);
}
if ($clientSecret !== '') {
$changedFields[] = 'client_secret';
}
}
if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) {
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
}
DB::transaction(function () use ($connection, $displayName, $clientId, $clientSecret, $targetType, $existingType, $existingClientId): void {
$connection->forceFill([
'display_name' => $displayName,
])->save();
if ($targetType === ProviderConnectionType::Dedicated && $clientSecret !== '') {
app(ProviderConnectionMutationService::class)->enableDedicatedOverride(
connection: $connection->fresh(),
clientId: $clientId,
clientSecret: $clientSecret,
);
}
if ($targetType === ProviderConnectionType::Dedicated && $existingType === ProviderConnectionType::Dedicated && $clientSecret === '' && $clientId !== '' && $clientId === $existingClientId) {
return;
}
if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) {
app(ProviderConnectionMutationService::class)->revertToPlatform($connection->fresh());
}
});
if (in_array('connection_type', $changedFields, true)) {
app(AuditLogger::class)->log(
tenant: $this->managedTenant,
action: 'provider_connection.connection_type_changed',
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'from_connection_type' => $existingType->value,
'to_connection_type' => $targetType->value,
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
],
],
actorId: (int) $user->getKey(),
actorEmail: (string) $user->email,
actorName: (string) $user->name,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
if ($changedFields !== []) {
app(AuditLogger::class)->log(
tenant: $this->managedTenant,
action: 'provider_connection.updated',
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'provider' => (string) $connection->provider,
'entra_tenant_id' => (string) $connection->entra_tenant_id,
'fields' => $changedFields,
'connection_type' => $targetType->value,
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
],
],
actorId: (int) $user->getKey(),
actorEmail: (string) $user->email,
actorName: (string) $user->name,
resourceType: 'provider_connection',
resourceId: (string) $connection->getKey(),
status: 'success',
);
}
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
unset(
$state['verification_operation_run_id'],
$state['bootstrap_operation_runs'],
$state['bootstrap_operation_types'],
);
$state['connection_recently_updated'] = true;
$this->onboardingSession->state = array_merge($state, [
'provider_connection_id' => (int) $connection->getKey(),
]);
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
Notification::make()
->title('Connection updated')
->success()
->send();
$this->initializeWizardData();
}
/**
* @return array<string, string>
*/
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();
}
}