TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
ahmido b6343d5c3a feat: unified managed tenant onboarding wizard (#88)
Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073).

## Summary
<!-- Kurz: Was ändert sich und warum? -->

## Spec-Driven Development (SDD)
- [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/`
- [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md`
- [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation)
- [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert

## Implementation
- [ ] Implementierung entspricht der Spec
- [ ] Edge cases / Fehlerfälle berücksichtigt
- [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes

## Tests
- [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit)
- [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`)

## Migration / Config / Ops (falls relevant)
- [ ] Migration(en) enthalten und getestet
- [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration)
- [ ] Neue Env Vars dokumentiert (`.env.example` / Doku)
- [ ] Queue/cron/storage Auswirkungen geprüft

## UI (Filament/Livewire) (falls relevant)
- [ ] UI-Flows geprüft
- [ ] Screenshots/Notizen hinzugefügt

## Notes
<!-- Links, Screenshots, Follow-ups, offene Punkte -->

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Reviewed-on: #88
2026-02-03 17:30:15 +00:00

1245 lines
46 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Filament\Pages\Workspaces;
use App\Filament\Pages\TenantDashboard;
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\Workspace;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\TenantMembershipManager;
use App\Services\OperationRunService;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationRegistry;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
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\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions as SchemaActions;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
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\Width;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ManagedTenantOnboardingWizard extends Page
{
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';
public Workspace $workspace;
public ?Tenant $managedTenant = null;
public ?TenantOnboardingSession $onboardingSession = null;
public ?int $selectedProviderConnectionId = null;
/**
* Filament schema state.
*
* @var array<string, mixed>
*/
public array $data = [];
/**
* @var array<int, string>
*/
public array $selectedBootstrapOperationTypes = [];
/**
* @return array<Action>
*/
protected function getHeaderActions(): array
{
return [];
}
public function mount(Workspace $workspace): void
{
$this->workspace = $workspace;
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! app(WorkspaceContext::class)->isMember($user, $workspace)) {
abort(404);
}
if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace)) {
abort(403);
}
$this->resumeLatestOnboardingSessionIfUnambiguous();
$this->initializeWizardData();
}
public function content(Schema $schema): Schema
{
return $schema
->statePath('data')
->schema([
Wizard::make([
Step::make('Identify managed tenant')
->description('Create or resume a pending managed tenant in this workspace.')
->schema([
Section::make('Tenant')
->schema([
TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->rules(['uuid'])
->maxLength(255),
TextInput::make('name')
->label('Display name')
->required()
->maxLength(255),
]),
])
->afterValidation(function (): void {
$tenantGuid = (string) ($this->data['tenant_id'] ?? '');
$tenantName = (string) ($this->data['name'] ?? '');
try {
$this->identifyManagedTenant([
'tenant_id' => $tenantGuid,
'name' => $tenantName,
]);
} catch (NotFoundHttpException) {
Notification::make()
->title('Tenant not available')
->body('This tenant cannot be onboarded in this workspace.')
->danger()
->send();
throw new Halt;
}
$this->initializeWizardData();
}),
Step::make('Provider connection')
->description('Select an existing connection or create a new one.')
->schema([
Section::make('Connection')
->schema([
Radio::make('connection_mode')
->label('Mode')
->options([
'existing' => 'Use existing connection',
'new' => 'Create new connection',
])
->required()
->default('existing')
->live(),
Select::make('provider_connection_id')
->label('Provider connection')
->required(fn (Get $get): bool => $get('connection_mode') === 'existing')
->options(fn (): array => $this->providerConnectionOptions())
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'),
TextInput::make('new_connection.display_name')
->label('Display name')
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
->maxLength(255)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
TextInput::make('new_connection.client_id')
->label('Client ID')
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
->maxLength(255)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
TextInput::make('new_connection.client_secret')
->label('Client secret')
->password()
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
->maxLength(255)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
->helperText('Stored encrypted and never shown again.'),
Toggle::make('new_connection.is_default')
->label('Make default')
->default(true)
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
]),
])
->afterValidation(function (): void {
if (! $this->managedTenant instanceof Tenant) {
throw new Halt;
}
$mode = (string) ($this->data['connection_mode'] ?? 'existing');
if ($mode === 'new') {
$new = is_array($this->data['new_connection'] ?? null) ? $this->data['new_connection'] : [];
$this->createProviderConnection([
'display_name' => (string) ($new['display_name'] ?? ''),
'client_id' => (string) ($new['client_id'] ?? ''),
'client_secret' => (string) ($new['client_secret'] ?? ''),
'is_default' => (bool) ($new['is_default'] ?? true),
]);
if (is_array($this->data['new_connection'] ?? null)) {
$this->data['new_connection']['client_secret'] = null;
}
} else {
$providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0);
if ($providerConnectionId <= 0) {
throw new Halt;
}
$this->selectProviderConnection($providerConnectionId);
}
$this->touchOnboardingSessionStep('connection');
$this->initializeWizardData();
}),
Step::make('Verify access')
->description('Run a queued verification check (Operation Run).')
->schema([
Section::make('Verification')
->schema([
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
->badge()
->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'),
SchemaActions::make([
Action::make('wizardStartVerification')
->label('Start verification')
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
->action(fn () => $this->startVerification()),
Action::make('wizardViewVerificationRun')
->label('View run')
->url(fn (): ?string => $this->verificationRunUrl())
->visible(fn (): bool => $this->verificationRunUrl() !== null),
]),
]),
])
->beforeValidation(function (): void {
if (! $this->verificationHasSucceeded()) {
Notification::make()
->title('Verification required')
->body('Run verification successfully before continuing.')
->warning()
->send();
throw new Halt;
}
$this->touchOnboardingSessionStep('verify');
}),
Step::make('Bootstrap (optional)')
->description('Optionally start inventory and compliance operations.')
->schema([
Section::make('Bootstrap')
->schema([
CheckboxList::make('bootstrap_operation_types')
->label('Bootstrap actions')
->options(fn (): array => $this->bootstrapOperationOptions())
->columns(1),
SchemaActions::make([
Action::make('wizardStartBootstrap')
->label('Start bootstrap')
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))),
]),
Text::make(fn (): string => $this->bootstrapRunsLabel())
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
]),
])
->afterValidation(function (): void {
$types = $this->data['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($types)
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''))
: [];
$this->touchOnboardingSessionStep('bootstrap');
}),
Step::make('Complete')
->description('Activate the tenant and finish onboarding.')
->schema([
Section::make('Finish')
->schema([
Text::make(fn (): string => $this->managedTenant instanceof Tenant
? 'Tenant: '.$this->managedTenant->name
: 'Tenant: not selected')
->badge()
->color('gray'),
Text::make(fn (): string => 'Verification: '.($this->verificationHasSucceeded() ? 'succeeded' : 'not yet succeeded'))
->badge()
->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'),
SchemaActions::make([
Action::make('wizardCompleteOnboarding')
->label('Complete onboarding')
->color('success')
->disabled(fn (): bool => ! $this->verificationHasSucceeded())
->action(fn () => $this->completeOnboarding()),
]),
]),
])
->beforeValidation(function (): void {
if (! $this->verificationHasSucceeded()) {
throw new Halt;
}
}),
])
->startOnStep(fn (): int => $this->computeWizardStartStep())
->skippable(false),
]);
}
private function resumeLatestOnboardingSessionIfUnambiguous(): void
{
$sessionCount = TenantOnboardingSession::query()
->where('workspace_id', (int) $this->workspace->getKey())
->whereNull('completed_at')
->count();
if ($sessionCount !== 1) {
return;
}
$session = TenantOnboardingSession::query()
->where('workspace_id', (int) $this->workspace->getKey())
->whereNull('completed_at')
->orderByDesc('updated_at')
->first();
if (! $session instanceof TenantOnboardingSession) {
return;
}
$tenant = Tenant::query()
->where('workspace_id', (int) $this->workspace->getKey())
->whereKey((int) $session->tenant_id)
->first();
if (! $tenant instanceof Tenant) {
return;
}
$this->managedTenant = $tenant;
$this->onboardingSession = $session;
$providerConnectionId = $session->state['provider_connection_id'] ?? null;
$this->selectedProviderConnectionId = is_int($providerConnectionId)
? $providerConnectionId
: $this->resolveDefaultProviderConnectionId($tenant);
$bootstrapTypes = $session->state['bootstrap_operation_types'] ?? [];
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
: [];
}
private function initializeWizardData(): void
{
if (! array_key_exists('connection_mode', $this->data)) {
$this->data['connection_mode'] = 'existing';
}
if ($this->managedTenant instanceof Tenant) {
$this->data['tenant_id'] ??= (string) $this->managedTenant->tenant_id;
$this->data['name'] ??= (string) $this->managedTenant->name;
}
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
if (is_int($providerConnectionId)) {
$this->data['provider_connection_id'] = $providerConnectionId;
$this->selectedProviderConnectionId = $providerConnectionId;
}
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
if (is_array($types)) {
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
}
}
if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) {
$this->data['provider_connection_id'] = $this->selectedProviderConnectionId;
}
}
private function computeWizardStartStep(): int
{
if (! $this->managedTenant instanceof Tenant) {
return 1;
}
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
return 2;
}
if (! $this->verificationHasSucceeded()) {
return 3;
}
return 4;
}
/**
* @return array<int, string>
*/
private function providerConnectionOptions(): array
{
if (! $this->managedTenant instanceof Tenant) {
return [];
}
return ProviderConnection::query()
->where('tenant_id', $this->managedTenant->getKey())
->orderByDesc('is_default')
->orderBy('display_name')
->pluck('display_name', 'id')
->all();
}
private function verificationStatusLabel(): string
{
if (! $this->managedTenant instanceof Tenant) {
return 'not started';
}
if ($this->verificationHasSucceeded()) {
return 'succeeded';
}
$runId = $this->onboardingSession?->state['verification_operation_run_id'] ?? null;
return is_int($runId) ? 'running or failed' : 'not started';
}
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 OperationRunLinks::view($runId, $this->managedTenant);
}
private function bootstrapRunsLabel(): string
{
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return '';
}
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
$runs = is_array($runs) ? $runs : [];
if ($runs === []) {
return '';
}
return sprintf('Started %d bootstrap run(s).', count($runs));
}
private function touchOnboardingSessionStep(string $step): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return;
}
$this->onboardingSession->forceFill([
'current_step' => $step,
'updated_by_user_id' => (int) $user->getKey(),
])->save();
}
private function authorizeWorkspaceMutation(User $user): void
{
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
abort(404);
}
if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $this->workspace)) {
abort(403);
}
}
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{tenant_id: string, name: string} $data
*/
public function identifyManagedTenant(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user);
$tenantGuid = $data['tenant_id'];
$tenantName = $data['name'];
DB::transaction(function () use ($user, $tenantGuid, $tenantName): void {
$auditLogger = app(WorkspaceAuditLogger::class);
$membershipManager = app(TenantMembershipManager::class);
$existingTenant = Tenant::query()
->withTrashed()
->where('tenant_id', $tenantGuid)
->first();
if ($existingTenant instanceof Tenant) {
if ($existingTenant->trashed() || $existingTenant->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);
}
if ($existingTenant->name !== $tenantName) {
$existingTenant->forceFill(['name' => $tenantName])->save();
}
$tenant = $existingTenant;
} else {
try {
$tenant = Tenant::query()->create([
'workspace_id' => (int) $this->workspace->getKey(),
'name' => $tenantName,
'tenant_id' => $tenantGuid,
'environment' => 'other',
'status' => 'pending',
]);
} 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', $tenantGuid)
->first();
if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
abort(404);
}
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', $this->workspace->getKey())
->where('tenant_id', $tenant->getKey())
->first();
$sessionWasCreated = false;
if (! $session instanceof TenantOnboardingSession) {
$session = new TenantOnboardingSession;
$session->workspace_id = (int) $this->workspace->getKey();
$session->tenant_id = (int) $tenant->getKey();
$session->started_by_user_id = (int) $user->getKey();
$sessionWasCreated = true;
}
$session->current_step = 'identify';
$session->state = array_merge($session->state ?? [], [
'tenant_id' => $tenantGuid,
]);
$session->updated_by_user_id = (int) $user->getKey();
$session->save();
$this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant);
if ($this->selectedProviderConnectionId !== null) {
$session->state = array_merge($session->state ?? [], [
'provider_connection_id' => (int) $this->selectedProviderConnectionId,
]);
$session->save();
}
$auditLogger->log(
workspace: $this->workspace,
action: ($sessionWasCreated
? AuditActionId::ManagedTenantOnboardingStart
: AuditActionId::ManagedTenantOnboardingResume
)->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'tenant_guid' => $tenantGuid,
'tenant_name' => $tenantName,
'onboarding_session_id' => (int) $session->getKey(),
'current_step' => (string) $session->current_step,
],
],
actor: $user,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
$this->managedTenant = $tenant;
$this->onboardingSession = $session;
});
Notification::make()
->title('Managed tenant identified')
->success()
->send();
}
public function selectProviderConnection(int $providerConnectionId): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$connection = ProviderConnection::query()
->where('tenant_id', $this->managedTenant->getKey())
->whereKey($providerConnectionId)
->first();
if (! $connection instanceof ProviderConnection) {
abort(404);
}
$this->selectedProviderConnectionId = (int) $connection->getKey();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]);
$this->onboardingSession->current_step = 'connection';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
Notification::make()
->title('Provider connection selected')
->success()
->send();
}
/**
* @param array{display_name: string, client_id: string, client_secret: string, is_default?: bool} $data
*/
public function createProviderConnection(array $data): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) {
abort(404);
}
$displayName = trim((string) ($data['display_name'] ?? ''));
$clientId = (string) ($data['client_id'] ?? '');
$clientSecret = (string) ($data['client_secret'] ?? '');
$makeDefault = (bool) ($data['is_default'] ?? false);
if ($displayName === '') {
abort(422);
}
/** @var ProviderConnection $connection */
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault): ProviderConnection {
$connection = ProviderConnection::query()->updateOrCreate(
[
'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id,
],
[
'display_name' => $displayName,
],
);
app(CredentialManager::class)->upsertClientSecretCredential(
connection: $connection,
clientId: $clientId,
clientSecret: $clientSecret,
);
if ($makeDefault) {
$connection->makeDefault();
}
return $connection;
});
$this->selectedProviderConnectionId = (int) $connection->getKey();
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
]);
$this->onboardingSession->current_step = 'connection';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
Notification::make()
->title('Provider connection created')
->success()
->send();
}
public function startVerification(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user);
if (! $this->managedTenant instanceof Tenant) {
Notification::make()
->title('Identify a managed tenant first')
->warning()
->send();
return;
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
$connection = $this->resolveSelectedProviderConnection($tenant);
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('No provider connection selected')
->body('Create or select a provider connection first.')
->warning()
->send();
return;
}
$result = app(ProviderOperationStartGate::class)->start(
tenant: $tenant,
connection: $connection,
operationType: 'provider.connection.check',
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void {
ProviderConnectionHealthCheckJob::dispatch(
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $connection->getKey(),
operationRun: $run,
);
},
initiator: $user,
extraContext: [
'wizard' => [
'flow' => 'managed_tenant_onboarding',
'step' => 'verification',
],
],
);
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
'provider_connection_id' => (int) $connection->getKey(),
'verification_operation_run_id' => (int) $result->run->getKey(),
]);
$this->onboardingSession->current_step = 'verify';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
$auditStatus = match ($result->status) {
'started' => 'success',
'deduped' => 'deduped',
'scope_busy' => 'blocked',
default => 'success',
};
app(WorkspaceAuditLogger::class)->log(
workspace: $this->workspace,
action: AuditActionId::ManagedTenantOnboardingVerificationStart->value,
context: [
'metadata' => [
'workspace_id' => (int) $this->workspace->getKey(),
'tenant_db_id' => (int) $tenant->getKey(),
'provider_connection_id' => (int) $connection->getKey(),
'operation_run_id' => (int) $result->run->getKey(),
'result' => (string) $result->status,
],
],
actor: $user,
status: $auditStatus,
resourceType: 'operation_run',
resourceId: (string) $result->run->getKey(),
);
if ($result->status === 'scope_busy') {
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active run to finish.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
return;
}
Notification::make()
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
->success()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result->run, $tenant)),
])
->send();
}
/**
* @param array<int, mixed> $operationTypes
*/
public function startBootstrap(array $operationTypes): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $this->verificationHasSucceeded()) {
Notification::make()
->title('Verification required')
->body('Run verification successfully before starting bootstrap actions.')
->warning()
->send();
return;
}
$connection = $this->resolveSelectedProviderConnection($tenant);
if (! $connection instanceof ProviderConnection) {
Notification::make()
->title('No provider connection selected')
->warning()
->send();
return;
}
$registry = app(ProviderOperationRegistry::class);
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== '')));
$types = array_values(array_filter(
$types,
static fn (string $type): bool => $type !== 'provider.connection.check' && $registry->isAllowed($type),
));
if (empty($types)) {
Notification::make()
->title('No bootstrap actions selected')
->warning()
->send();
return;
}
/** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
$lockedConnection = ProviderConnection::query()
->whereKey($connection->getKey())
->lockForUpdate()
->firstOrFail();
$activeRun = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->active()
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
->orderByDesc('id')
->first();
if ($activeRun instanceof OperationRun) {
return [
'status' => 'scope_busy',
'run' => $activeRun,
];
}
$runsService = app(OperationRunService::class);
$bootstrapRuns = [];
foreach ($types as $operationType) {
$definition = $registry->get($operationType);
$context = [
'wizard' => [
'flow' => 'managed_tenant_onboarding',
'step' => 'bootstrap',
],
'provider' => $lockedConnection->provider,
'module' => $definition['module'],
'provider_connection_id' => (int) $lockedConnection->getKey(),
'target_scope' => [
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
],
];
$run = $runsService->ensureRunWithIdentity(
tenant: $tenant,
type: $operationType,
identityInputs: [
'provider_connection_id' => (int) $lockedConnection->getKey(),
],
context: $context,
initiator: $user,
);
if ($run->wasRecentlyCreated) {
$this->dispatchBootstrapJob(
operationType: $operationType,
tenantId: (int) $tenant->getKey(),
userId: (int) $user->getKey(),
providerConnectionId: (int) $lockedConnection->getKey(),
run: $run,
);
}
$bootstrapRuns[$operationType] = (int) $run->getKey();
}
return [
'status' => 'started',
'runs' => $bootstrapRuns,
];
});
if ($result['status'] === 'scope_busy') {
Notification::make()
->title('Another operation is already running')
->body('Please wait for the active run to finish.')
->warning()
->actions([
Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($result['run'], $tenant)),
])
->send();
return;
}
$bootstrapRuns = $result['runs'];
if ($this->onboardingSession instanceof TenantOnboardingSession) {
$state = $this->onboardingSession->state ?? [];
$existing = $state['bootstrap_operation_runs'] ?? [];
$existing = is_array($existing) ? $existing : [];
$state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns);
$state['bootstrap_operation_types'] = $types;
$this->onboardingSession->state = $state;
$this->onboardingSession->current_step = 'bootstrap';
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
$this->onboardingSession->save();
}
Notification::make()
->title('Bootstrap started')
->success()
->send();
}
private function dispatchBootstrapJob(
string $operationType,
int $tenantId,
int $userId,
int $providerConnectionId,
OperationRun $run,
): void {
match ($operationType) {
'inventory.sync' => ProviderInventorySyncJob::dispatch(
tenantId: $tenantId,
userId: $userId,
providerConnectionId: $providerConnectionId,
operationRun: $run,
),
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
tenantId: $tenantId,
userId: $userId,
providerConnectionId: $providerConnectionId,
operationRun: $run,
),
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
};
}
public function verificationSucceeded(): bool
{
return $this->verificationHasSucceeded();
}
public function completeOnboarding(): void
{
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
$this->authorizeWorkspaceMutation($user);
if (! $this->managedTenant instanceof Tenant) {
abort(404);
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
abort(404);
}
if (! $this->verificationHasSucceeded()) {
Notification::make()
->title('Verification required')
->body('Complete verification successfully before finishing onboarding.')
->warning()
->send();
return;
}
$tenant = $this->managedTenant->fresh();
if (! $tenant instanceof Tenant) {
abort(404);
}
DB::transaction(function () use ($tenant, $user): void {
$tenant->update(['status' => 'active']);
$this->onboardingSession->forceFill([
'completed_at' => now(),
'current_step' => 'complete',
'updated_by_user_id' => (int) $user->getKey(),
])->save();
});
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
}
private function verificationHasSucceeded(): bool
{
if (! $this->managedTenant instanceof Tenant) {
return false;
}
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
return false;
}
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
if (! is_int($runId)) {
return false;
}
$run = OperationRun::query()
->where('tenant_id', (int) $this->managedTenant->getKey())
->whereKey($runId)
->first();
if (! $run instanceof OperationRun) {
return false;
}
return $run->status === 'completed' && $run->outcome === 'succeeded';
}
/**
* @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('tenant_id', (int) $tenant->getKey())
->where('is_default', true)
->orderByDesc('id')
->value('id');
if (is_int($id)) {
return $id;
}
$fallback = ProviderConnection::query()
->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('tenant_id', (int) $tenant->getKey())
->whereKey($providerConnectionId)
->first();
}
}