TenantAtlas/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
Ahmed Darrazi ab0ffff1d1 feat(onboarding): enterprise wizard + tenantless run viewer
- Canonical /admin/onboarding entry point; legacy routes 404\n- Tenantless run viewer at /admin/operations/{run} with membership-based 404\n- RBAC UX (disabled controls + tooltips) and server-side 403\n- DB-only rendering/refresh; contract registry enforced\n- Adds migrations + tests + spec artifacts
2026-02-04 23:00:06 +01:00

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