Kontext / Ziel
Diese PR liefert den einzigen kanonischen Onboarding-Entry unter /admin/onboarding (workspace-first, tenantless bis zur Aktivierung) und ergänzt einen tenantless OperationRun-Viewer unter /admin/operations/{run} mit membership→404 Semantik.
Was ist enthalten?
Single entry point: /admin/onboarding ist der einzige Einstieg; Legacy Entry Points liefern echte 404 (keine Redirects).
Wizard v1 (Enterprise): idempotentes Identifizieren eines Managed Tenants (per Entra Tenant ID), resumable Session-Flow.
Provider Connection Step: Auswahl oder Erstellung, Secrets werden nie erneut gerendert / nicht in Session-State persistiert.
Verification als OperationRun: async/queued, DB-only Rendering im Wizard (keine Graph-Calls beim Rendern).
Tenantless Run Viewing: /admin/operations/{run} funktioniert ohne ausgewählten Workspace/Tenant, aber bleibt über Workspace-Mitgliedschaft autorisiert (non-member → 404).
RBAC-UX Semantik: non-member → 404, member ohne Capability → UI disabled + tooltip, server-side Action → 403.
Auditability: Aktivierung/Overrides sind auditierbar, stable action IDs, keine Secrets.
Tech / Version-Safety
Filament v5 / Livewire v4.0+ kompatibel.
Laravel 11+: Panel Provider Registrierung in providers.php (unverändert).
Tests / Format
vendor/bin/sail bin pint --dirty
Full suite: vendor/bin/sail artisan test --no-ansi → 984 passed, 5 skipped (exit 0)
Ops / Deployment Notes
Keine zusätzlichen Services vorausgesetzt.
Falls Assets registriert wurden: Deployment weiterhin mit php artisan filament:assets (wie üblich im Projekt).
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box>
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #90
1641 lines
63 KiB
PHP
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();
|
|
}
|
|
}
|