## Summary - add canonical managed-tenant onboarding draft routing with explicit draft identity and landing vs concrete draft behavior - implement draft lifecycle, authorization, attribution, picker UX, resume-stage resolution, and auditable cancel or completion semantics - add focused feature, unit, and browser coverage plus Spec 138 artifacts for the onboarding draft resume flow ## Validation - `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Audit/OnboardingDraftAuditTest.php tests/Feature/Onboarding/OnboardingDraftAccessTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php tests/Feature/Onboarding/OnboardingDraftMultiTabTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftRoutingTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php tests/Feature/Onboarding/OnboardingVerificationClustersTest.php tests/Feature/Onboarding/OnboardingVerificationTest.php tests/Feature/Onboarding/OnboardingVerificationV1_5UxTest.php tests/Feature/Verification/VerificationReportViewerDbOnlyTest.php tests/Unit/Onboarding tests/Unit/VerificationReportSanitizerEvidenceKindsTest.php tests/Browser/OnboardingDraftRefreshTest.php tests/Browser/OnboardingDraftVerificationResumeTest.php` - passed: 69 tests, 251 assertions Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #167
3269 lines
132 KiB
PHP
3269 lines
132 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Workspaces;
|
|
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Filament\Support\VerificationReportChangeIndicator;
|
|
use App\Filament\Support\VerificationReportViewer;
|
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
use App\Jobs\ProviderInventorySyncJob;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\VerificationCheckAcknowledgement;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\TenantMembershipManager;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
|
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Providers\ProviderConnectionMutationService;
|
|
use App\Services\Providers\ProviderConnectionStateProjector;
|
|
use App\Services\Providers\ProviderOperationRegistry;
|
|
use App\Services\Providers\ProviderOperationStartGate;
|
|
use App\Services\Verification\VerificationCheckAcknowledgementService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Badges\BadgeCatalog;
|
|
use App\Support\Badges\BadgeDomain;
|
|
use App\Support\Onboarding\OnboardingDraftStage;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OpsUx\OperationUxPresenter;
|
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
|
use App\Support\Providers\ProviderConnectionType;
|
|
use App\Support\Providers\ProviderConsentStatus;
|
|
use App\Support\Providers\ProviderReasonCodes;
|
|
use App\Support\Providers\ProviderVerificationStatus;
|
|
use App\Support\Verification\VerificationCheckStatus;
|
|
use App\Support\Verification\VerificationReportOverall;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\CheckboxList;
|
|
use Filament\Forms\Components\Radio;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Forms\Components\ViewField;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Actions as SchemaActions;
|
|
use Filament\Schemas\Components\Callout;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Text;
|
|
use Filament\Schemas\Components\UnorderedList;
|
|
use Filament\Schemas\Components\Utilities\Get;
|
|
use Filament\Schemas\Components\Wizard;
|
|
use Filament\Schemas\Components\Wizard\Step;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Enums\FontWeight;
|
|
use Filament\Support\Enums\Width;
|
|
use Filament\Support\Exceptions\Halt;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class ManagedTenantOnboardingWizard extends Page
|
|
{
|
|
use AuthorizesRequests;
|
|
|
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
|
|
protected Width|string|null $maxContentWidth = Width::ScreenExtraLarge;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static ?string $title = 'Managed tenant onboarding';
|
|
|
|
protected static ?string $slug = 'onboarding';
|
|
|
|
/**
|
|
* Disable the simple-layout topbar to prevent lazy-loaded
|
|
* DatabaseNotifications from triggering Livewire update 404s
|
|
* on this workspace-scoped route.
|
|
*/
|
|
protected function getLayoutData(): array
|
|
{
|
|
return [
|
|
'hasTopbar' => false,
|
|
];
|
|
}
|
|
|
|
public Workspace $workspace;
|
|
|
|
public ?Tenant $managedTenant = null;
|
|
|
|
public ?TenantOnboardingSession $onboardingSession = null;
|
|
|
|
public ?int $selectedProviderConnectionId = null;
|
|
|
|
public bool $showDraftPicker = false;
|
|
|
|
public bool $showStartState = false;
|
|
|
|
/**
|
|
* Filament schema state.
|
|
*
|
|
* @var array<string, mixed>
|
|
*/
|
|
public array $data = [];
|
|
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
public array $selectedBootstrapOperationTypes = [];
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$actions = [];
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$actions[] = Action::make('back_to_onboarding_landing')
|
|
->label('All onboarding drafts')
|
|
->color('gray')
|
|
->url(route('admin.onboarding'));
|
|
}
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession && $this->onboardingSession->isResumable()) {
|
|
$actions[] = Action::make('cancel_onboarding_draft')
|
|
->label('Cancel draft')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->modalHeading('Cancel onboarding draft')
|
|
->modalDescription('This draft will become non-resumable. Confirm only if you intend to stop this onboarding flow.')
|
|
->visible(fn (): bool => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL))
|
|
->action(fn () => $this->cancelOnboardingDraft());
|
|
}
|
|
|
|
return $actions;
|
|
}
|
|
|
|
public function mount(TenantOnboardingSession|int|string|null $onboardingDraft = null): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
|
|
if ($workspaceId === null) {
|
|
if ($onboardingDraft !== null) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->redirect('/admin/choose-workspace');
|
|
|
|
return;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
|
|
if (! $workspace instanceof Workspace) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! app(WorkspaceContext::class)->isMember($user, $workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->workspace = $workspace;
|
|
|
|
$this->authorize('viewAny', TenantOnboardingSession::class);
|
|
|
|
if ($onboardingDraft !== null) {
|
|
$this->loadOnboardingDraft($user, $onboardingDraft);
|
|
$this->initializeWizardData();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->resolveLandingState($user)) {
|
|
return;
|
|
}
|
|
|
|
$this->showStartState = true;
|
|
$this->initializeWizardData();
|
|
}
|
|
|
|
public function content(Schema $schema): Schema
|
|
{
|
|
if ($this->showDraftPicker) {
|
|
return $schema->schema($this->draftPickerSchema());
|
|
}
|
|
|
|
if ($this->showsNonResumableSummary()) {
|
|
return $schema->schema($this->nonResumableSummarySchema());
|
|
}
|
|
|
|
return $schema
|
|
->statePath('data')
|
|
->schema([
|
|
...$this->resumeContextSchema(),
|
|
Wizard::make([
|
|
Step::make('Identify managed tenant')
|
|
->description('Create or resume a managed tenant in this workspace.')
|
|
->schema([
|
|
Section::make('Tenant')
|
|
->schema([
|
|
TextInput::make('entra_tenant_id')
|
|
->label('Entra Tenant ID (GUID)')
|
|
->required()
|
|
->placeholder('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
|
|
->rules(['uuid'])
|
|
->maxLength(255),
|
|
Select::make('environment')
|
|
->label('Environment')
|
|
->required()
|
|
->options([
|
|
'prod' => 'Production',
|
|
'staging' => 'Staging',
|
|
'dev' => 'Development',
|
|
'other' => 'Other',
|
|
])
|
|
->default('other'),
|
|
TextInput::make('name')
|
|
->label('Display name')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('primary_domain')
|
|
->label('Primary domain (optional)')
|
|
->maxLength(255),
|
|
Textarea::make('notes')
|
|
->label('Notes (optional)')
|
|
->rows(3)
|
|
->maxLength(2000),
|
|
]),
|
|
])
|
|
->afterValidation(function (): void {
|
|
$entraTenantId = (string) ($this->data['entra_tenant_id'] ?? '');
|
|
$environment = (string) ($this->data['environment'] ?? 'other');
|
|
$tenantName = (string) ($this->data['name'] ?? '');
|
|
$primaryDomain = (string) ($this->data['primary_domain'] ?? '');
|
|
$notes = (string) ($this->data['notes'] ?? '');
|
|
|
|
try {
|
|
$this->identifyManagedTenant([
|
|
'entra_tenant_id' => $entraTenantId,
|
|
'environment' => $environment,
|
|
'name' => $tenantName,
|
|
'primary_domain' => $primaryDomain,
|
|
'notes' => $notes,
|
|
]);
|
|
} catch (NotFoundHttpException) {
|
|
Notification::make()
|
|
->title('Tenant not available')
|
|
->body('This tenant cannot be onboarded in this workspace.')
|
|
->danger()
|
|
->send();
|
|
|
|
throw new Halt;
|
|
}
|
|
|
|
$this->initializeWizardData();
|
|
}),
|
|
|
|
Step::make('Provider connection')
|
|
->description('Select an existing connection or create a new one.')
|
|
->schema([
|
|
Section::make('Connection')
|
|
->schema([
|
|
Radio::make('connection_mode')
|
|
->label('Mode')
|
|
->options([
|
|
'existing' => 'Use existing connection',
|
|
'new' => 'Create new connection',
|
|
])
|
|
->required()
|
|
->default('existing')
|
|
->live(),
|
|
Select::make('provider_connection_id')
|
|
->label('Provider connection')
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'existing')
|
|
->options(fn (): array => $this->providerConnectionOptions())
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing')
|
|
->hintActions([
|
|
Action::make('edit_selected_connection')
|
|
->label('Edit selected connection')
|
|
->icon('heroicon-m-pencil-square')
|
|
->color('gray')
|
|
->slideOver()
|
|
->modalHeading('Edit provider connection')
|
|
->modalDescription('Changes apply to this workspace connection.')
|
|
->modalSubmitActionLabel('Save changes')
|
|
->closeModalByClickingAway(false)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'
|
|
&& is_numeric($get('provider_connection_id')))
|
|
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE))
|
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE)
|
|
? null
|
|
: 'You don\'t have permission to edit connections.')
|
|
->fillForm(function (Get $get): array {
|
|
$recordId = $get('provider_connection_id');
|
|
|
|
if (! is_numeric($recordId)) {
|
|
return [];
|
|
}
|
|
|
|
return $this->inlineEditSelectedConnectionFill((int) $recordId);
|
|
})
|
|
->form([
|
|
TextInput::make('display_name')
|
|
->label('Connection name')
|
|
->required()
|
|
->maxLength(255),
|
|
TextInput::make('entra_tenant_id')
|
|
->label('Directory (tenant) ID')
|
|
->disabled()
|
|
->dehydrated(false),
|
|
Toggle::make('uses_dedicated_override')
|
|
->label('Dedicated override')
|
|
->helperText('Explicit exception path for a customer-managed app registration.')
|
|
->visible(fn (): bool => $this->canManageDedicatedOverride())
|
|
->live(),
|
|
TextInput::make('client_id')
|
|
->label('Dedicated app (client) ID')
|
|
->required(fn (Get $get): bool => (bool) $get('uses_dedicated_override'))
|
|
->maxLength(255)
|
|
->helperText('If you change the dedicated App (client) ID, enter the matching new client secret below.')
|
|
->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')),
|
|
TextInput::make('client_secret')
|
|
->label('New dedicated client secret')
|
|
->password()
|
|
->revealable(false)
|
|
->maxLength(255)
|
|
->helperText('Required when enabling dedicated mode or changing the dedicated App (client) ID. The existing secret is never shown.')
|
|
->visible(fn (Get $get): bool => (bool) $get('uses_dedicated_override')),
|
|
])
|
|
->action(function (array $data, Get $get): void {
|
|
$recordId = $get('provider_connection_id');
|
|
|
|
if (! is_numeric($recordId)) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->updateSelectedProviderConnectionInline((int) $recordId, $data);
|
|
}),
|
|
]),
|
|
|
|
TextInput::make('new_connection.display_name')
|
|
->label('Display name')
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->maxLength(255)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
|
TextInput::make('new_connection.connection_type')
|
|
->label('Connection type')
|
|
->default('Platform connection')
|
|
->disabled()
|
|
->dehydrated(false)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->helperText('Managed centrally by platform. Grant admin consent after creating the connection.'),
|
|
TextInput::make('new_connection.platform_app_id')
|
|
->label('Platform app ID')
|
|
->default(fn (): string => $this->platformAppClientId())
|
|
->disabled()
|
|
->dehydrated(false)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
|
Toggle::make('new_connection.uses_dedicated_override')
|
|
->label('Dedicated override')
|
|
->helperText('Explicit exception path for a customer-managed app registration.')
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && $this->canManageDedicatedOverride())
|
|
->live(),
|
|
TextInput::make('new_connection.client_id')
|
|
->label('Dedicated app (client) ID')
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override'))
|
|
->maxLength(255)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')),
|
|
TextInput::make('new_connection.client_secret')
|
|
->label('Dedicated client secret')
|
|
->password()
|
|
->revealable(false)
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override'))
|
|
->maxLength(255)
|
|
->helperText('Required only for the dedicated override path. The secret is never shown again after save.')
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new' && (bool) $get('new_connection.uses_dedicated_override')),
|
|
Toggle::make('new_connection.is_default')
|
|
->label('Make default')
|
|
->default(true)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
|
]),
|
|
])
|
|
->afterValidation(function (): void {
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
throw new Halt;
|
|
}
|
|
|
|
$mode = (string) ($this->data['connection_mode'] ?? 'existing');
|
|
|
|
if ($mode === 'new') {
|
|
$new = is_array($this->data['new_connection'] ?? null) ? $this->data['new_connection'] : [];
|
|
|
|
$this->createProviderConnection([
|
|
'display_name' => (string) ($new['display_name'] ?? ''),
|
|
'connection_type' => (bool) ($new['uses_dedicated_override'] ?? false)
|
|
? ProviderConnectionType::Dedicated->value
|
|
: ProviderConnectionType::Platform->value,
|
|
'client_id' => (string) ($new['client_id'] ?? ''),
|
|
'client_secret' => (string) ($new['client_secret'] ?? ''),
|
|
'is_default' => (bool) ($new['is_default'] ?? true),
|
|
]);
|
|
} else {
|
|
$providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0);
|
|
|
|
if ($providerConnectionId <= 0) {
|
|
throw new Halt;
|
|
}
|
|
|
|
$this->selectProviderConnection($providerConnectionId);
|
|
}
|
|
|
|
$this->touchOnboardingSessionStep('connection');
|
|
$this->initializeWizardData();
|
|
}),
|
|
|
|
Step::make('Verify access')
|
|
->description('Run a queued verification check (Operation Run).')
|
|
->schema([
|
|
Section::make('Verification')
|
|
->schema([
|
|
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
|
|
->badge()
|
|
->color(fn (): string => $this->verificationStatusColor()),
|
|
Text::make('Connection updated — re-run verification to refresh results.')
|
|
->visible(fn (): bool => $this->connectionRecentlyUpdated()),
|
|
Text::make('The selected provider connection has changed since this verification run. Start verification again to validate the current connection.')
|
|
->visible(fn (): bool => $this->verificationRunIsStaleForSelectedConnection()),
|
|
Text::make('Verification is in progress. Use “Refresh results” to see the latest stored status.')
|
|
->visible(fn (): bool => $this->verificationStatus() === 'in_progress'),
|
|
SchemaActions::make([
|
|
Action::make('wizardStartVerification')
|
|
->label('Start verification')
|
|
->visible(fn (): bool => $this->managedTenant instanceof Tenant && ! $this->verificationRunIsActive())
|
|
->disabled(fn (): bool => ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START))
|
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START)
|
|
? null
|
|
: 'You do not have permission to start verification.')
|
|
->action(fn () => $this->startVerification()),
|
|
Action::make('wizardRefreshVerification')
|
|
->label('Refresh')
|
|
->visible(fn (): bool => $this->verificationRunUrl() !== null && $this->verificationStatus() === 'in_progress')
|
|
->action(fn () => $this->refreshVerificationStatus()),
|
|
]),
|
|
ViewField::make('verification_report')
|
|
->label('')
|
|
->default(null)
|
|
->view('filament.forms.components.managed-tenant-onboarding-verification-report')
|
|
->viewData(fn (): array => $this->verificationReportViewData())
|
|
->visible(fn (): bool => $this->verificationRunUrl() !== null),
|
|
]),
|
|
])
|
|
->beforeValidation(function (): void {
|
|
if (! $this->verificationCanProceed()) {
|
|
Notification::make()
|
|
->title('Verification required')
|
|
->body('Complete verification for the selected provider connection before continuing.')
|
|
->warning()
|
|
->send();
|
|
|
|
throw new Halt;
|
|
}
|
|
|
|
$this->touchOnboardingSessionStep('verify');
|
|
}),
|
|
|
|
Step::make('Bootstrap (optional)')
|
|
->description('Optionally start inventory and compliance operations.')
|
|
->schema([
|
|
Section::make('Bootstrap')
|
|
->schema([
|
|
CheckboxList::make('bootstrap_operation_types')
|
|
->label('Bootstrap actions')
|
|
->options(fn (): array => $this->bootstrapOperationOptions())
|
|
->columns(1),
|
|
SchemaActions::make([
|
|
Action::make('wizardStartBootstrap')
|
|
->label('Start bootstrap')
|
|
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
|
->disabled(fn (): bool => ! $this->canStartAnyBootstrap())
|
|
->tooltip(fn (): ?string => $this->canStartAnyBootstrap()
|
|
? null
|
|
: 'You do not have permission to start bootstrap actions.')
|
|
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))),
|
|
]),
|
|
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
|
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
|
]),
|
|
])
|
|
->afterValidation(function (): void {
|
|
$types = $this->data['bootstrap_operation_types'] ?? [];
|
|
$this->selectedBootstrapOperationTypes = is_array($types)
|
|
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''))
|
|
: [];
|
|
|
|
$this->touchOnboardingSessionStep('bootstrap');
|
|
}),
|
|
|
|
Step::make('Complete')
|
|
->description('Review configuration and activate the tenant.')
|
|
->schema([
|
|
Section::make('Review & Activate')
|
|
->description('Review the onboarding summary before activating this tenant.')
|
|
->schema([
|
|
Section::make('Onboarding summary')
|
|
->compact()
|
|
->columns(2)
|
|
->schema([
|
|
Text::make('Tenant')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->completionSummaryTenantLine())
|
|
->weight(FontWeight::SemiBold),
|
|
Text::make('Provider connection')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->completionSummaryConnectionSummary())
|
|
->weight(FontWeight::SemiBold),
|
|
Text::make('Verification')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->verificationStatusLabel().' — '.$this->completionSummaryVerificationDetail())
|
|
->badge()
|
|
->color(fn (): string => $this->verificationStatusColor()),
|
|
Text::make('Bootstrap')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
|
|
->badge()
|
|
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
|
]),
|
|
Callout::make('After activation')
|
|
->description('This action is recorded in the audit log and cannot be undone from this wizard.')
|
|
->info()
|
|
->footer([
|
|
UnorderedList::make([
|
|
'Tenant status will be set to Active.',
|
|
'Backup, inventory, and compliance operations become available.',
|
|
'The provider connection will be used for all Graph API calls.',
|
|
]),
|
|
]),
|
|
Toggle::make('override_blocked')
|
|
->label('Override blocked verification')
|
|
->helperText('Owner-only. Requires a reason and will be recorded in the audit log.')
|
|
->visible(fn (): bool => $this->verificationStatus() === 'blocked'
|
|
&& $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)),
|
|
Textarea::make('override_reason')
|
|
->label('Override reason')
|
|
->required(fn (Get $get): bool => (bool) $get('override_blocked'))
|
|
->visible(fn (Get $get): bool => (bool) $get('override_blocked'))
|
|
->rows(3)
|
|
->maxLength(500),
|
|
SchemaActions::make([
|
|
Action::make('wizardCompleteOnboarding')
|
|
->label('Activate tenant')
|
|
->color('success')
|
|
->requiresConfirmation()
|
|
->modalHeading('Activate tenant')
|
|
->modalDescription(fn (): string => $this->managedTenant instanceof Tenant
|
|
? sprintf('Are you sure you want to activate "%s"? This will make the tenant operational.', $this->managedTenant->name)
|
|
: 'Are you sure you want to activate this tenant?')
|
|
->modalSubmitActionLabel('Yes, activate')
|
|
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
|
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
|
? null
|
|
: 'Owner required to activate.')
|
|
->action(fn () => $this->completeOnboarding()),
|
|
]),
|
|
]),
|
|
])
|
|
->beforeValidation(function (): void {
|
|
if (! $this->canCompleteOnboarding()) {
|
|
throw new Halt;
|
|
}
|
|
}),
|
|
])
|
|
->startOnStep(fn (): int => $this->computeWizardStartStep())
|
|
->skippable(false),
|
|
]);
|
|
}
|
|
|
|
private function resolveLandingState(User $user): bool
|
|
{
|
|
$drafts = $this->availableDraftsFor($user);
|
|
|
|
if ($drafts->count() === 1) {
|
|
$draft = $drafts->first();
|
|
|
|
if ($draft instanceof TenantOnboardingSession) {
|
|
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft]));
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if ($drafts->count() > 1) {
|
|
$this->showDraftPicker = true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function loadOnboardingDraft(User $user, TenantOnboardingSession|int|string $onboardingDraft): void
|
|
{
|
|
$draft = app(OnboardingDraftResolver::class)->resolve($onboardingDraft, $user, $this->workspace);
|
|
|
|
$this->showDraftPicker = false;
|
|
$this->showStartState = false;
|
|
$this->onboardingSession = $draft;
|
|
|
|
$tenant = $draft->tenant;
|
|
|
|
if ($tenant instanceof Tenant && (int) $tenant->workspace_id === (int) $this->workspace->getKey()) {
|
|
$this->managedTenant = $tenant;
|
|
}
|
|
|
|
$providerConnectionId = $draft->state['provider_connection_id'] ?? null;
|
|
$this->selectedProviderConnectionId = is_int($providerConnectionId)
|
|
? $providerConnectionId
|
|
: ($tenant instanceof Tenant ? $this->resolveDefaultProviderConnectionId($tenant) : null);
|
|
|
|
$bootstrapTypes = $draft->state['bootstrap_operation_types'] ?? [];
|
|
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
|
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
|
|
: [];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, TenantOnboardingSession>
|
|
*/
|
|
private function availableDraftsFor(User $user): Collection
|
|
{
|
|
return app(OnboardingDraftResolver::class)->resumableDraftsFor($user, $this->workspace);
|
|
}
|
|
|
|
/**
|
|
* @return array<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
private function draftPickerSchema(): array
|
|
{
|
|
$drafts = $this->currentUser() instanceof User
|
|
? $this->availableDraftsFor($this->currentUser())
|
|
: collect();
|
|
|
|
$components = [
|
|
Callout::make('Multiple onboarding drafts are available.')
|
|
->description('Select a draft to resume or start a new onboarding flow explicitly.')
|
|
->warning(),
|
|
SchemaActions::make([
|
|
Action::make('start_new_onboarding_draft')
|
|
->label('Start new onboarding')
|
|
->color('primary')
|
|
->action(fn () => $this->startNewOnboardingDraft()),
|
|
])->key('draft_picker_start_actions'),
|
|
];
|
|
|
|
foreach ($drafts as $draft) {
|
|
$components[] = Section::make($this->draftTitle($draft))
|
|
->description($this->draftDescription($draft))
|
|
->compact()
|
|
->columns(2)
|
|
->schema([
|
|
Text::make('Current stage')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->draftStageLabel($draft))
|
|
->badge()
|
|
->color(fn (): string => $this->draftStageColor($draft)),
|
|
Text::make('Status')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $draft->status()->label())
|
|
->badge()
|
|
->color(fn (): string => $this->draftStatusColor($draft)),
|
|
Text::make('Started by')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $draft->startedByUser?->name ?? 'Unknown'),
|
|
Text::make('Last updated by')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $draft->updatedByUser?->name ?? 'Unknown'),
|
|
Text::make('Last updated')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $draft->updated_at?->diffForHumans() ?? '—'),
|
|
Text::make('Draft age')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
|
SchemaActions::make([
|
|
Action::make('resume_draft_'.$draft->getKey())
|
|
->label('Resume onboarding draft')
|
|
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), true)),
|
|
Action::make('view_draft_'.$draft->getKey())
|
|
->label('View summary')
|
|
->color('gray')
|
|
->action(fn () => $this->resumeOnboardingDraft((int) $draft->getKey(), false)),
|
|
])->key('draft_picker_actions_'.$draft->getKey()),
|
|
]);
|
|
}
|
|
|
|
return $components;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
private function resumeContextSchema(): array
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
Section::make('Onboarding draft')
|
|
->compact()
|
|
->columns(2)
|
|
->schema([
|
|
Text::make('Tenant')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->draftTitle($this->onboardingSession))
|
|
->weight(FontWeight::SemiBold),
|
|
Text::make('Current stage')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->draftStageLabel($this->onboardingSession))
|
|
->badge()
|
|
->color(fn (): string => $this->draftStageColor($this->onboardingSession)),
|
|
Text::make('Started by')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->onboardingSession?->startedByUser?->name ?? 'Unknown'),
|
|
Text::make('Last updated by')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $this->onboardingSession?->updatedByUser?->name ?? 'Unknown'),
|
|
]),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, \Filament\Schemas\Components\Component>
|
|
*/
|
|
private function nonResumableSummarySchema(): array
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return [];
|
|
}
|
|
|
|
$statusLabel = $this->onboardingSession->status()->label();
|
|
|
|
return [
|
|
Callout::make("This onboarding draft is {$statusLabel}.")
|
|
->description('Completed and cancelled drafts remain viewable, but they cannot return to editable wizard mode.')
|
|
->warning(),
|
|
...$this->resumeContextSchema(),
|
|
Section::make('Draft summary')
|
|
->compact()
|
|
->columns(2)
|
|
->schema([
|
|
Text::make('Status')
|
|
->color('gray'),
|
|
Text::make(fn (): string => $statusLabel)
|
|
->badge()
|
|
->color(fn (): string => $this->draftStatusColor($this->onboardingSession)),
|
|
Text::make('Primary domain')
|
|
->color('gray'),
|
|
Text::make(fn (): string => (string) (($this->onboardingSession?->state['primary_domain'] ?? null) ?: '—')),
|
|
Text::make('Environment')
|
|
->color('gray'),
|
|
Text::make(fn (): string => (string) (($this->onboardingSession?->state['environment'] ?? null) ?: '—')),
|
|
Text::make('Notes')
|
|
->color('gray'),
|
|
Text::make(fn (): string => (string) (($this->onboardingSession?->state['notes'] ?? null) ?: '—')),
|
|
]),
|
|
SchemaActions::make([
|
|
Action::make('return_to_onboarding_landing')
|
|
->label('Return to onboarding')
|
|
->color('gray')
|
|
->url(route('admin.onboarding')),
|
|
]),
|
|
];
|
|
}
|
|
|
|
private function startNewOnboardingDraft(): void
|
|
{
|
|
$this->showDraftPicker = false;
|
|
$this->showStartState = true;
|
|
$this->managedTenant = null;
|
|
$this->onboardingSession = null;
|
|
$this->selectedProviderConnectionId = null;
|
|
$this->selectedBootstrapOperationTypes = [];
|
|
$this->data = [];
|
|
|
|
$this->initializeWizardData();
|
|
}
|
|
|
|
private function resumeOnboardingDraft(int $draftId, bool $logSelection): void
|
|
{
|
|
$user = $this->currentUser();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$draft = app(OnboardingDraftResolver::class)->resolve($draftId, $user, $this->workspace);
|
|
|
|
if ($logSelection) {
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingDraftSelected->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'onboarding_session_id' => (int) $draft->getKey(),
|
|
'tenant_db_id' => $draft->tenant_id !== null ? (int) $draft->tenant_id : null,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $draft->getKey(),
|
|
);
|
|
}
|
|
|
|
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft]));
|
|
}
|
|
|
|
private function cancelOnboardingDraft(): void
|
|
{
|
|
$user = $this->currentUser();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->authorize('cancel', $this->onboardingSession);
|
|
|
|
if (! $this->onboardingSession->isResumable()) {
|
|
Notification::make()
|
|
->title('Draft is not resumable')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->onboardingSession->forceFill([
|
|
'current_step' => 'cancelled',
|
|
'cancelled_at' => now(),
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
])->save();
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingCancelled->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
|
|
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $this->onboardingSession->getKey(),
|
|
);
|
|
|
|
$this->onboardingSession->refresh();
|
|
|
|
Notification::make()
|
|
->title('Onboarding draft cancelled')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
private function showsNonResumableSummary(): bool
|
|
{
|
|
return $this->onboardingSession instanceof TenantOnboardingSession
|
|
&& ! $this->onboardingSession->isResumable();
|
|
}
|
|
|
|
private function draftTitle(TenantOnboardingSession $draft): string
|
|
{
|
|
$state = is_array($draft->state) ? $draft->state : [];
|
|
$tenantName = $state['tenant_name'] ?? $draft->tenant?->name ?? null;
|
|
$tenantName = is_string($tenantName) && trim($tenantName) !== '' ? trim($tenantName) : 'Unidentified tenant';
|
|
$entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id;
|
|
|
|
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
|
return sprintf('%s (%s)', $tenantName, trim($entraTenantId));
|
|
}
|
|
|
|
return $tenantName;
|
|
}
|
|
|
|
private function draftDescription(TenantOnboardingSession $draft): string
|
|
{
|
|
$state = is_array($draft->state) ? $draft->state : [];
|
|
$environment = $state['environment'] ?? null;
|
|
$primaryDomain = $state['primary_domain'] ?? null;
|
|
|
|
return collect([
|
|
is_string($environment) && $environment !== '' ? ucfirst($environment) : null,
|
|
is_string($primaryDomain) && $primaryDomain !== '' ? $primaryDomain : null,
|
|
])->filter()->implode(' · ');
|
|
}
|
|
|
|
private function draftStageLabel(TenantOnboardingSession $draft): string
|
|
{
|
|
return app(OnboardingDraftStageResolver::class)->resolve($draft)->label();
|
|
}
|
|
|
|
private function draftStageColor(TenantOnboardingSession $draft): string
|
|
{
|
|
return match (app(OnboardingDraftStageResolver::class)->resolve($draft)) {
|
|
OnboardingDraftStage::Identify => 'gray',
|
|
OnboardingDraftStage::ConnectProvider => 'info',
|
|
OnboardingDraftStage::VerifyAccess => 'warning',
|
|
OnboardingDraftStage::Bootstrap => 'info',
|
|
OnboardingDraftStage::Review => 'success',
|
|
OnboardingDraftStage::Completed => 'success',
|
|
OnboardingDraftStage::Cancelled => 'danger',
|
|
};
|
|
}
|
|
|
|
private function draftStatusColor(TenantOnboardingSession $draft): string
|
|
{
|
|
return match ($draft->status()->value) {
|
|
'draft' => 'info',
|
|
'completed' => 'success',
|
|
'cancelled' => 'danger',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
|
|
private function currentUser(): ?User
|
|
{
|
|
$user = auth()->user();
|
|
|
|
return $user instanceof User ? $user : null;
|
|
}
|
|
|
|
private function initializeWizardData(): void
|
|
{
|
|
// Ensure all entangled schema state paths exist at render time.
|
|
// Livewire v4 can throw when entangling to missing nested array keys.
|
|
$this->data['notes'] ??= '';
|
|
$this->data['override_blocked'] ??= false;
|
|
$this->data['override_reason'] ??= '';
|
|
$this->data['new_connection'] ??= [];
|
|
|
|
if (! array_key_exists('connection_mode', $this->data)) {
|
|
$this->data['connection_mode'] = 'existing';
|
|
}
|
|
|
|
if (is_array($this->data['new_connection'])) {
|
|
$this->data['new_connection']['connection_type'] ??= 'Platform connection';
|
|
$this->data['new_connection']['platform_app_id'] ??= $this->platformAppClientId();
|
|
$this->data['new_connection']['uses_dedicated_override'] ??= false;
|
|
$this->data['new_connection']['is_default'] ??= true;
|
|
}
|
|
|
|
if ($this->managedTenant instanceof Tenant) {
|
|
$this->data['entra_tenant_id'] ??= (string) $this->managedTenant->tenant_id;
|
|
$this->data['environment'] ??= (string) ($this->managedTenant->environment ?? 'other');
|
|
$this->data['name'] ??= (string) $this->managedTenant->name;
|
|
$this->data['primary_domain'] ??= (string) ($this->managedTenant->domain ?? '');
|
|
|
|
$notes = is_array($this->managedTenant->metadata) ? ($this->managedTenant->metadata['notes'] ?? null) : null;
|
|
if (is_string($notes) && trim($notes) !== '') {
|
|
$this->data['notes'] ??= trim($notes);
|
|
}
|
|
}
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
|
|
|
|
if (isset($state['entra_tenant_id']) && is_string($state['entra_tenant_id']) && trim($state['entra_tenant_id']) !== '') {
|
|
$this->data['entra_tenant_id'] ??= trim($state['entra_tenant_id']);
|
|
}
|
|
|
|
if (isset($state['environment']) && is_string($state['environment']) && trim($state['environment']) !== '') {
|
|
$this->data['environment'] ??= trim($state['environment']);
|
|
}
|
|
|
|
if (isset($state['tenant_name']) && is_string($state['tenant_name']) && trim($state['tenant_name']) !== '') {
|
|
$this->data['name'] ??= trim($state['tenant_name']);
|
|
}
|
|
|
|
if (array_key_exists('primary_domain', $state)) {
|
|
$domain = $state['primary_domain'];
|
|
|
|
if (is_string($domain)) {
|
|
$this->data['primary_domain'] ??= $domain;
|
|
}
|
|
}
|
|
|
|
if (array_key_exists('notes', $state)) {
|
|
$notes = $state['notes'];
|
|
|
|
if (is_string($notes)) {
|
|
$this->data['notes'] ??= $notes;
|
|
}
|
|
}
|
|
|
|
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
|
if (is_int($providerConnectionId)) {
|
|
$this->data['provider_connection_id'] = $providerConnectionId;
|
|
$this->selectedProviderConnectionId = $providerConnectionId;
|
|
}
|
|
|
|
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
|
if (is_array($types)) {
|
|
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
|
}
|
|
}
|
|
|
|
if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) {
|
|
$this->data['provider_connection_id'] = $this->selectedProviderConnectionId;
|
|
}
|
|
}
|
|
|
|
private function computeWizardStartStep(): int
|
|
{
|
|
return app(OnboardingDraftStageResolver::class)
|
|
->resolve($this->onboardingSession)
|
|
->wizardStep();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function providerConnectionOptions(): array
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
return ProviderConnection::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', $this->managedTenant->getKey())
|
|
->orderByDesc('is_default')
|
|
->orderBy('display_name')
|
|
->pluck('display_name', 'id')
|
|
->all();
|
|
}
|
|
|
|
private function verificationStatusLabel(): string
|
|
{
|
|
return BadgeCatalog::spec(
|
|
BadgeDomain::ManagedTenantOnboardingVerificationStatus,
|
|
$this->verificationStatus(),
|
|
)->label;
|
|
}
|
|
|
|
private function verificationStatus(): string
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return 'not_started';
|
|
}
|
|
|
|
if (! $this->verificationRunMatchesSelectedConnection($run)) {
|
|
return 'needs_attention';
|
|
}
|
|
|
|
if ($run->status !== OperationRunStatus::Completed->value) {
|
|
return 'in_progress';
|
|
}
|
|
|
|
return match ($this->verificationReportOverall()) {
|
|
VerificationReportOverall::Blocked->value => 'blocked',
|
|
VerificationReportOverall::NeedsAttention->value => 'needs_attention',
|
|
VerificationReportOverall::Ready->value => 'ready',
|
|
VerificationReportOverall::Running->value => 'in_progress',
|
|
default => $this->verificationStatusFromRunOutcome($run),
|
|
};
|
|
}
|
|
|
|
private function verificationStatusFromRunOutcome(OperationRun $run): string
|
|
{
|
|
if ($run->outcome === OperationRunOutcome::Blocked->value) {
|
|
return 'blocked';
|
|
}
|
|
|
|
if ($run->outcome === OperationRunOutcome::Succeeded->value) {
|
|
return 'ready';
|
|
}
|
|
|
|
if ($run->outcome === OperationRunOutcome::PartiallySucceeded->value) {
|
|
return 'needs_attention';
|
|
}
|
|
|
|
if ($run->outcome !== OperationRunOutcome::Failed->value) {
|
|
return 'needs_attention';
|
|
}
|
|
|
|
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
|
|
|
if ($failures === []) {
|
|
return 'blocked';
|
|
}
|
|
|
|
foreach ($failures as $failure) {
|
|
if (! is_array($failure)) {
|
|
continue;
|
|
}
|
|
|
|
$reasonCode = $failure['reason_code'] ?? null;
|
|
|
|
if (! is_string($reasonCode) || $reasonCode === '') {
|
|
continue;
|
|
}
|
|
|
|
if (in_array($reasonCode, ['provider_auth_failed', 'provider_permission_denied', 'permission_denied', 'provider_consent_missing'], true)) {
|
|
return 'blocked';
|
|
}
|
|
}
|
|
|
|
return 'needs_attention';
|
|
}
|
|
|
|
private function verificationReportOverall(): ?string
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return null;
|
|
}
|
|
|
|
$report = VerificationReportViewer::report($run);
|
|
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : null;
|
|
$overall = $summary['overall'] ?? null;
|
|
|
|
if (! is_string($overall) || ! in_array($overall, VerificationReportOverall::values(), true)) {
|
|
return null;
|
|
}
|
|
|
|
return $overall;
|
|
}
|
|
|
|
/**
|
|
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}|null
|
|
*/
|
|
private function verificationReportCounts(): ?array
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return null;
|
|
}
|
|
|
|
$report = VerificationReportViewer::report($run);
|
|
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : null;
|
|
$counts = is_array($summary['counts'] ?? null) ? $summary['counts'] : null;
|
|
|
|
if (! is_array($counts)) {
|
|
return null;
|
|
}
|
|
|
|
foreach (['total', 'pass', 'fail', 'warn', 'skip', 'running'] as $key) {
|
|
if (! is_int($counts[$key] ?? null) || $counts[$key] < 0) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'total' => $counts['total'],
|
|
'pass' => $counts['pass'],
|
|
'fail' => $counts['fail'],
|
|
'warn' => $counts['warn'],
|
|
'skip' => $counts['skip'],
|
|
'running' => $counts['running'],
|
|
];
|
|
}
|
|
|
|
private function verificationRunIsActive(): bool
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
return $run instanceof OperationRun
|
|
&& $run->status !== OperationRunStatus::Completed->value;
|
|
}
|
|
|
|
private function verificationStatusColor(): string
|
|
{
|
|
return BadgeCatalog::spec(
|
|
BadgeDomain::ManagedTenantOnboardingVerificationStatus,
|
|
$this->verificationStatus(),
|
|
)->color;
|
|
}
|
|
|
|
private function verificationRunUrl(): ?string
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return null;
|
|
}
|
|
|
|
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
|
|
|
|
if (! is_int($runId)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->tenantlessOperationRunUrl($runId);
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* run: array<string, mixed>|null,
|
|
* runUrl: string|null,
|
|
* report: array<string, mixed>|null,
|
|
* fingerprint: string|null,
|
|
* changeIndicator: array{state: 'no_changes'|'changed', previous_report_id: int}|null,
|
|
* previousRunUrl: string|null,
|
|
* canAcknowledge: bool,
|
|
* acknowledgements: array<string, array{
|
|
* check_key: string,
|
|
* ack_reason: string,
|
|
* acknowledged_at: string|null,
|
|
* expires_at: string|null,
|
|
* acknowledged_by: array{id: int, name: string}|null
|
|
* }>
|
|
* }
|
|
*/
|
|
private function verificationReportViewData(): array
|
|
{
|
|
$run = $this->verificationRun();
|
|
$runUrl = $this->verificationRunUrl();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return [
|
|
'run' => null,
|
|
'runUrl' => $runUrl,
|
|
'report' => null,
|
|
'fingerprint' => null,
|
|
'changeIndicator' => null,
|
|
'previousRunUrl' => null,
|
|
'canAcknowledge' => false,
|
|
'acknowledgements' => [],
|
|
];
|
|
}
|
|
|
|
$report = VerificationReportViewer::report($run);
|
|
$fingerprint = is_array($report) ? VerificationReportViewer::fingerprint($report) : null;
|
|
|
|
$changeIndicator = VerificationReportChangeIndicator::forRun($run);
|
|
$previousRunUrl = $changeIndicator === null
|
|
? null
|
|
: $this->tenantlessOperationRunUrl((int) $changeIndicator['previous_report_id']);
|
|
|
|
$user = auth()->user();
|
|
$canAcknowledge = $user instanceof User && $this->managedTenant instanceof Tenant
|
|
? $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)
|
|
: false;
|
|
|
|
$acknowledgements = VerificationCheckAcknowledgement::query()
|
|
->where('tenant_id', (int) $run->tenant_id)
|
|
->where('workspace_id', (int) $run->workspace_id)
|
|
->where('operation_run_id', (int) $run->getKey())
|
|
->with('acknowledgedByUser')
|
|
->get()
|
|
->mapWithKeys(static function (VerificationCheckAcknowledgement $ack): array {
|
|
$user = $ack->acknowledgedByUser;
|
|
|
|
return [
|
|
(string) $ack->check_key => [
|
|
'check_key' => (string) $ack->check_key,
|
|
'ack_reason' => (string) $ack->ack_reason,
|
|
'acknowledged_at' => $ack->acknowledged_at?->toJSON(),
|
|
'expires_at' => $ack->expires_at?->toJSON(),
|
|
'acknowledged_by' => $user instanceof User
|
|
? [
|
|
'id' => (int) $user->getKey(),
|
|
'name' => (string) $user->name,
|
|
]
|
|
: null,
|
|
],
|
|
];
|
|
})
|
|
->all();
|
|
|
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
|
$targetScope = $context['target_scope'] ?? [];
|
|
$targetScope = is_array($targetScope) ? $targetScope : [];
|
|
|
|
$failures = is_array($run->failure_summary ?? null) ? $run->failure_summary : [];
|
|
$verificationReport = VerificationReportViewer::report($run);
|
|
|
|
return [
|
|
'run' => [
|
|
'id' => (int) $run->getKey(),
|
|
'type' => (string) $run->type,
|
|
'status' => (string) $run->status,
|
|
'outcome' => (string) $run->outcome,
|
|
'initiator_name' => (string) $run->initiator_name,
|
|
'started_at' => $run->started_at?->toJSON(),
|
|
'completed_at' => $run->completed_at?->toJSON(),
|
|
'target_scope' => $targetScope,
|
|
'failures' => $failures,
|
|
],
|
|
'runUrl' => $runUrl,
|
|
'report' => $report,
|
|
'fingerprint' => $fingerprint,
|
|
'changeIndicator' => $changeIndicator,
|
|
'previousRunUrl' => $previousRunUrl,
|
|
'canAcknowledge' => $canAcknowledge,
|
|
'acknowledgements' => $acknowledgements,
|
|
];
|
|
}
|
|
|
|
public function acknowledgeVerificationCheckAction(): Action
|
|
{
|
|
return Action::make('acknowledgeVerificationCheck')
|
|
->label('Acknowledge')
|
|
->color('gray')
|
|
->requiresConfirmation()
|
|
->modalHeading('Acknowledge issue')
|
|
->modalDescription('This records an acknowledgement for governance and audit. It does not change the verification outcome.')
|
|
->form([
|
|
Textarea::make('ack_reason')
|
|
->label('Reason')
|
|
->required()
|
|
->maxLength(160)
|
|
->rows(3),
|
|
TextInput::make('expires_at')
|
|
->label('Expiry (optional)')
|
|
->helperText('Optional timestamp (informational only).')
|
|
->maxLength(64),
|
|
])
|
|
->action(function (array $data, array $arguments): void {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
throw new NotFoundHttpException;
|
|
}
|
|
|
|
$checkKey = (string) ($arguments['check_key'] ?? '');
|
|
$ackReason = (string) ($data['ack_reason'] ?? '');
|
|
$expiresAt = $data['expires_at'] ?? null;
|
|
$expiresAt = is_string($expiresAt) ? $expiresAt : null;
|
|
|
|
try {
|
|
app(VerificationCheckAcknowledgementService::class)->acknowledge(
|
|
tenant: $tenant,
|
|
run: $run,
|
|
checkKey: $checkKey,
|
|
ackReason: $ackReason,
|
|
expiresAt: $expiresAt,
|
|
actor: $user,
|
|
);
|
|
} catch (InvalidArgumentException $e) {
|
|
Notification::make()
|
|
->title('Unable to acknowledge')
|
|
->body($e->getMessage())
|
|
->danger()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Issue acknowledged')
|
|
->success()
|
|
->send();
|
|
})
|
|
->visible(function (array $arguments): bool {
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return false;
|
|
}
|
|
|
|
if (! $user->can(Capabilities::TENANT_VERIFICATION_ACKNOWLEDGE, $this->managedTenant)) {
|
|
return false;
|
|
}
|
|
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
$checkKey = trim((string) ($arguments['check_key'] ?? ''));
|
|
|
|
if ($checkKey === '') {
|
|
return false;
|
|
}
|
|
|
|
$ackExists = VerificationCheckAcknowledgement::query()
|
|
->where('operation_run_id', (int) $run->getKey())
|
|
->where('check_key', $checkKey)
|
|
->exists();
|
|
|
|
if ($ackExists) {
|
|
return false;
|
|
}
|
|
|
|
$report = VerificationReportViewer::report($run);
|
|
|
|
if (! is_array($report)) {
|
|
return false;
|
|
}
|
|
|
|
$checks = $report['checks'] ?? null;
|
|
$checks = is_array($checks) ? $checks : [];
|
|
|
|
foreach ($checks as $check) {
|
|
if (! is_array($check)) {
|
|
continue;
|
|
}
|
|
|
|
if (($check['key'] ?? null) !== $checkKey) {
|
|
continue;
|
|
}
|
|
|
|
$status = $check['status'] ?? null;
|
|
|
|
return is_string($status) && in_array($status, [
|
|
VerificationCheckStatus::Fail->value,
|
|
VerificationCheckStatus::Warn->value,
|
|
], true);
|
|
}
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
private function bootstrapRunsLabel(): string
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return '';
|
|
}
|
|
|
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
$runs = is_array($runs) ? $runs : [];
|
|
|
|
if ($runs === []) {
|
|
return '';
|
|
}
|
|
|
|
return sprintf('Started %d bootstrap run(s).', count($runs));
|
|
}
|
|
|
|
private function touchOnboardingSessionStep(string $step): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return;
|
|
}
|
|
|
|
$this->onboardingSession->forceFill([
|
|
'current_step' => $step,
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
])->save();
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingDraftUpdated->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => $this->onboardingSession->tenant_id !== null ? (int) $this->onboardingSession->tenant_id : null,
|
|
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
|
'current_step' => $step,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $this->onboardingSession->getKey(),
|
|
);
|
|
}
|
|
|
|
private function authorizeWorkspaceMutation(User $user, string $capability): void
|
|
{
|
|
$this->authorizeWorkspaceMember($user);
|
|
|
|
if (! $user->can($capability, $this->workspace)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function authorizeEditableDraft(User $user): void
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return;
|
|
}
|
|
|
|
$this->authorize('update', $this->onboardingSession);
|
|
|
|
if (! $this->onboardingSession->isResumable()) {
|
|
abort(404);
|
|
}
|
|
}
|
|
|
|
private function authorizeWorkspaceMember(User $user): void
|
|
{
|
|
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
|
|
abort(404);
|
|
}
|
|
}
|
|
|
|
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
|
|
{
|
|
$workspaceId = DB::table('tenant_memberships')
|
|
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
|
|
->where('tenant_memberships.tenant_id', (int) $tenant->getKey())
|
|
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
|
->value('workspace_memberships.workspace_id');
|
|
|
|
return $workspaceId === null ? null : (int) $workspaceId;
|
|
}
|
|
|
|
/**
|
|
* @param array{entra_tenant_id: string, environment: string, name: string, primary_domain?: string, notes?: string} $data
|
|
*/
|
|
public function identifyManagedTenant(array $data): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_IDENTIFY);
|
|
|
|
$entraTenantId = (string) ($data['entra_tenant_id'] ?? '');
|
|
$tenantName = (string) ($data['name'] ?? '');
|
|
$environment = (string) ($data['environment'] ?? 'other');
|
|
$primaryDomain = trim((string) ($data['primary_domain'] ?? ''));
|
|
$notes = trim((string) ($data['notes'] ?? ''));
|
|
|
|
if ($entraTenantId === '' || $tenantName === '') {
|
|
abort(422);
|
|
}
|
|
|
|
if (! in_array($environment, ['prod', 'staging', 'dev', 'other'], true)) {
|
|
abort(422);
|
|
}
|
|
|
|
$primaryDomain = $primaryDomain !== '' ? $primaryDomain : null;
|
|
$notes = $notes !== '' ? $notes : null;
|
|
|
|
$notificationTitle = 'Onboarding draft ready';
|
|
$notificationBody = null;
|
|
|
|
DB::transaction(function () use ($user, $entraTenantId, $tenantName, $environment, $primaryDomain, $notes, &$notificationTitle, &$notificationBody): void {
|
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
|
$membershipManager = app(TenantMembershipManager::class);
|
|
$currentDraftId = $this->onboardingSession?->getKey();
|
|
|
|
$existingTenant = Tenant::query()
|
|
->withTrashed()
|
|
->where('tenant_id', $entraTenantId)
|
|
->first();
|
|
|
|
if ($existingTenant instanceof Tenant) {
|
|
if ($existingTenant->trashed() || $existingTenant->status === Tenant::STATUS_ARCHIVED) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($existingTenant->workspace_id === null) {
|
|
$resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant);
|
|
|
|
if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) {
|
|
$existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save();
|
|
}
|
|
}
|
|
|
|
if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
$existingTenant->forceFill([
|
|
'name' => $tenantName,
|
|
'environment' => $environment,
|
|
'domain' => $primaryDomain,
|
|
'status' => $existingTenant->status === Tenant::STATUS_DRAFT ? Tenant::STATUS_ONBOARDING : $existingTenant->status,
|
|
'metadata' => array_merge(is_array($existingTenant->metadata) ? $existingTenant->metadata : [], array_filter([
|
|
'notes' => $notes,
|
|
], static fn ($value): bool => $value !== null)),
|
|
])->save();
|
|
|
|
$tenant = $existingTenant;
|
|
} else {
|
|
try {
|
|
$tenant = Tenant::query()->create([
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'name' => $tenantName,
|
|
'tenant_id' => $entraTenantId,
|
|
'domain' => $primaryDomain,
|
|
'environment' => $environment,
|
|
'status' => Tenant::STATUS_ONBOARDING,
|
|
'metadata' => array_filter([
|
|
'notes' => $notes,
|
|
], static fn ($value): bool => $value !== null),
|
|
]);
|
|
} catch (QueryException $exception) {
|
|
// Race-safe global uniqueness: if another workspace created the tenant_id first,
|
|
// treat it as deny-as-not-found.
|
|
$existingTenant = Tenant::query()
|
|
->withTrashed()
|
|
->where('tenant_id', $entraTenantId)
|
|
->first();
|
|
|
|
if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id === (int) $this->workspace->getKey()) {
|
|
$tenant = $existingTenant;
|
|
} else {
|
|
throw $exception;
|
|
}
|
|
}
|
|
}
|
|
|
|
$membershipManager->addMember(
|
|
tenant: $tenant,
|
|
actor: $user,
|
|
member: $user,
|
|
role: 'owner',
|
|
source: 'manual',
|
|
);
|
|
|
|
$ownerCount = TenantMembership::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('role', 'owner')
|
|
->count();
|
|
|
|
if ($ownerCount === 0) {
|
|
throw new RuntimeException('Tenant must have at least one owner.');
|
|
}
|
|
|
|
$session = TenantOnboardingSession::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('entra_tenant_id', $entraTenantId)
|
|
->resumable()
|
|
->first();
|
|
|
|
$sessionWasCreated = false;
|
|
|
|
if (! $session instanceof TenantOnboardingSession) {
|
|
$session = new TenantOnboardingSession;
|
|
$session->workspace_id = (int) $this->workspace->getKey();
|
|
$session->entra_tenant_id = $entraTenantId;
|
|
$session->tenant_id = (int) $tenant->getKey();
|
|
$session->started_by_user_id = (int) $user->getKey();
|
|
$sessionWasCreated = true;
|
|
}
|
|
|
|
$session->entra_tenant_id = $entraTenantId;
|
|
$session->tenant_id = (int) $tenant->getKey();
|
|
$session->current_step = 'identify';
|
|
$session->state = array_merge($session->state ?? [], [
|
|
'entra_tenant_id' => $entraTenantId,
|
|
'tenant_name' => $tenantName,
|
|
'environment' => $environment,
|
|
'primary_domain' => $primaryDomain,
|
|
'notes' => $notes,
|
|
]);
|
|
$session->updated_by_user_id = (int) $user->getKey();
|
|
$session->save();
|
|
|
|
$this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant);
|
|
|
|
if ($this->selectedProviderConnectionId !== null) {
|
|
$session->state = array_merge($session->state ?? [], [
|
|
'provider_connection_id' => (int) $this->selectedProviderConnectionId,
|
|
]);
|
|
$session->save();
|
|
}
|
|
|
|
$didResumeExistingDraft = ! $sessionWasCreated
|
|
&& ($currentDraftId === null || (int) $currentDraftId !== (int) $session->getKey());
|
|
|
|
$notificationTitle = $didResumeExistingDraft
|
|
? 'Existing onboarding draft resumed'
|
|
: 'Onboarding draft ready';
|
|
$notificationBody = $didResumeExistingDraft
|
|
? 'A resumable draft already exists for this tenant. TenantAtlas reopened it instead of creating a duplicate.'
|
|
: null;
|
|
|
|
$auditLogger->log(
|
|
workspace: $this->workspace,
|
|
action: ($sessionWasCreated
|
|
? AuditActionId::ManagedTenantOnboardingStart
|
|
: AuditActionId::ManagedTenantOnboardingResume
|
|
)->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'entra_tenant_id' => $entraTenantId,
|
|
'tenant_name' => $tenantName,
|
|
'onboarding_session_id' => (int) $session->getKey(),
|
|
'current_step' => (string) $session->current_step,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
|
|
$this->managedTenant = $tenant;
|
|
$this->onboardingSession = $session;
|
|
});
|
|
|
|
$notification = Notification::make()
|
|
->title($notificationTitle)
|
|
->success();
|
|
|
|
if (is_string($notificationBody) && $notificationBody !== '') {
|
|
$notification->body($notificationBody);
|
|
}
|
|
|
|
$notification->send();
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->redirect(route('admin.onboarding.draft', ['onboardingDraft' => $this->onboardingSession]));
|
|
}
|
|
}
|
|
|
|
public function selectProviderConnection(int $providerConnectionId): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_VIEW);
|
|
$this->authorizeEditableDraft($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = ProviderConnection::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', $this->managedTenant->getKey())
|
|
->whereKey($providerConnectionId)
|
|
->first();
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
abort(404);
|
|
}
|
|
|
|
$previousProviderConnectionId = $this->selectedProviderConnectionId;
|
|
|
|
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
|
|
state: array_merge($this->onboardingSession->state ?? [], [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
]),
|
|
previousProviderConnectionId: $previousProviderConnectionId,
|
|
newProviderConnectionId: (int) $connection->getKey(),
|
|
);
|
|
$this->onboardingSession->current_step = 'connection';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Provider connection selected')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array{display_name: string, connection_type?: string, client_id?: string, client_secret?: string, is_default?: bool} $data
|
|
*/
|
|
public function createProviderConnection(array $data): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
|
$this->authorizeEditableDraft($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
$displayName = trim((string) ($data['display_name'] ?? ''));
|
|
$requestedConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? '')));
|
|
$clientId = trim((string) ($data['client_id'] ?? ''));
|
|
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
|
|
$makeDefault = (bool) ($data['is_default'] ?? false);
|
|
$usesDedicatedCredential = $requestedConnectionType === ProviderConnectionType::Dedicated
|
|
|| $clientId !== ''
|
|
|| $clientSecret !== '';
|
|
|
|
if ($displayName === '') {
|
|
abort(422);
|
|
}
|
|
|
|
if ($usesDedicatedCredential) {
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
|
}
|
|
|
|
if ($usesDedicatedCredential && ($clientId === '' || $clientSecret === '')) {
|
|
abort(422);
|
|
}
|
|
|
|
$wasExistingConnection = false;
|
|
$previousConnectionType = null;
|
|
|
|
/** @var ProviderConnection $connection */
|
|
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection {
|
|
$projectedState = app(ProviderConnectionStateProjector::class)->project(
|
|
connectionType: ProviderConnectionType::Platform,
|
|
consentStatus: ProviderConsentStatus::Required,
|
|
verificationStatus: ProviderVerificationStatus::Unknown,
|
|
);
|
|
|
|
$connection = ProviderConnection::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('provider', 'microsoft')
|
|
->where('entra_tenant_id', (string) $tenant->tenant_id)
|
|
->first();
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
$connection = ProviderConnection::query()->create([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
'display_name' => $displayName,
|
|
'connection_type' => ProviderConnectionType::Platform->value,
|
|
'status' => $projectedState['status'],
|
|
'consent_status' => ProviderConsentStatus::Required->value,
|
|
'consent_granted_at' => null,
|
|
'consent_last_checked_at' => null,
|
|
'consent_error_code' => null,
|
|
'consent_error_message' => null,
|
|
'verification_status' => ProviderVerificationStatus::Unknown->value,
|
|
'health_status' => $projectedState['health_status'],
|
|
'migration_review_required' => false,
|
|
'migration_reviewed_at' => null,
|
|
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
|
|
'last_error_message' => null,
|
|
]);
|
|
} else {
|
|
$wasExistingConnection = true;
|
|
$previousConnectionType = $connection->connection_type instanceof ProviderConnectionType
|
|
? $connection->connection_type
|
|
: ProviderConnectionType::Platform;
|
|
|
|
$connection->forceFill([
|
|
'workspace_id' => (int) $tenant->workspace_id,
|
|
'display_name' => $displayName,
|
|
])->save();
|
|
}
|
|
|
|
if ($usesDedicatedCredential) {
|
|
$connection = app(ProviderConnectionMutationService::class)->enableDedicatedOverride(
|
|
connection: $connection,
|
|
clientId: $clientId,
|
|
clientSecret: $clientSecret,
|
|
);
|
|
}
|
|
|
|
if ($makeDefault) {
|
|
$connection->makeDefault();
|
|
}
|
|
|
|
return $connection;
|
|
});
|
|
|
|
$auditLogger = app(AuditLogger::class);
|
|
$actorId = (int) $user->getKey();
|
|
$actorEmail = (string) $user->email;
|
|
$actorName = (string) $user->name;
|
|
|
|
if (! $wasExistingConnection) {
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'provider_connection.created',
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'provider' => (string) $connection->provider,
|
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
'connection_type' => $connection->connection_type->value,
|
|
'source' => 'managed_tenant_onboarding_wizard.create',
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
status: 'success',
|
|
);
|
|
}
|
|
|
|
if ($previousConnectionType instanceof ProviderConnectionType && $previousConnectionType !== $connection->connection_type) {
|
|
$auditLogger->log(
|
|
tenant: $tenant,
|
|
action: 'provider_connection.connection_type_changed',
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'provider' => (string) $connection->provider,
|
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
'from_connection_type' => $previousConnectionType->value,
|
|
'to_connection_type' => $connection->connection_type->value,
|
|
'source' => 'managed_tenant_onboarding_wizard.create',
|
|
],
|
|
],
|
|
actorId: $actorId,
|
|
actorEmail: $actorEmail,
|
|
actorName: $actorName,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
status: 'success',
|
|
);
|
|
}
|
|
|
|
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$previousProviderConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
|
$previousProviderConnectionId = is_int($previousProviderConnectionId)
|
|
? $previousProviderConnectionId
|
|
: (is_numeric($previousProviderConnectionId) ? (int) $previousProviderConnectionId : null);
|
|
|
|
$this->onboardingSession->state = $this->resetDependentOnboardingStateOnConnectionChange(
|
|
state: array_merge($this->onboardingSession->state ?? [], [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
]),
|
|
previousProviderConnectionId: $previousProviderConnectionId,
|
|
newProviderConnectionId: (int) $connection->getKey(),
|
|
);
|
|
$this->onboardingSession->current_step = 'connection';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingProviderConnectionChanged->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $this->managedTenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'onboarding_session_id' => $this->onboardingSession?->getKey(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
);
|
|
|
|
Notification::make()
|
|
->title('Provider connection created')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
private function platformAppClientId(): string
|
|
{
|
|
$clientId = trim((string) config('graph.client_id'));
|
|
|
|
return $clientId !== '' ? $clientId : 'Platform app not configured';
|
|
}
|
|
|
|
private function canManageDedicatedOverride(): bool
|
|
{
|
|
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
|
}
|
|
|
|
public function startVerification(): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START);
|
|
$this->authorizeEditableDraft($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('Identify a managed tenant first')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($tenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
Notification::make()
|
|
->title('No provider connection selected')
|
|
->body('Create or select a provider connection first.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$result = app(ProviderOperationStartGate::class)->start(
|
|
tenant: $tenant,
|
|
connection: $connection,
|
|
operationType: 'provider.connection.check',
|
|
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void {
|
|
ProviderConnectionHealthCheckJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
operationRun: $run,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'wizard' => [
|
|
'flow' => 'managed_tenant_onboarding',
|
|
'step' => 'verification',
|
|
],
|
|
],
|
|
);
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'verification_operation_run_id' => (int) $result->run->getKey(),
|
|
'connection_recently_updated' => false,
|
|
]);
|
|
$this->onboardingSession->current_step = 'verify';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
$auditStatus = match ($result->status) {
|
|
'started' => 'success',
|
|
'deduped' => 'deduped',
|
|
'scope_busy' => 'blocked',
|
|
default => 'success',
|
|
};
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingVerificationStart->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'operation_run_id' => (int) $result->run->getKey(),
|
|
'result' => (string) $result->status,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: $auditStatus,
|
|
resourceType: 'operation_run',
|
|
resourceId: (string) $result->run->getKey(),
|
|
);
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingVerificationPersisted->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
|
'operation_run_id' => (int) $result->run->getKey(),
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $this->onboardingSession->getKey(),
|
|
);
|
|
}
|
|
|
|
if ($result->status === 'scope_busy') {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
Notification::make()
|
|
->title('Another operation is already running')
|
|
->body('Please wait for the active run to finish.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($result->status === 'blocked') {
|
|
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
|
? (string) $result->run->context['reason_code']
|
|
: 'unknown_error';
|
|
|
|
$actions = [
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
];
|
|
|
|
$nextSteps = $result->run->context['next_steps'] ?? [];
|
|
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
|
|
|
foreach ($nextSteps as $index => $step) {
|
|
if (! is_array($step)) {
|
|
continue;
|
|
}
|
|
|
|
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
|
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
|
|
|
if ($label === '' || $url === '') {
|
|
continue;
|
|
}
|
|
|
|
$actions[] = Action::make('next_step_'.$index)
|
|
->label($label)
|
|
->url($url);
|
|
|
|
break;
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Verification blocked')
|
|
->body("Blocked by provider configuration ({$reasonCode}).")
|
|
->warning()
|
|
->actions($actions)
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
if ($result->status === 'deduped') {
|
|
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
OperationUxPresenter::queuedToast((string) $result->run->type)
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
])
|
|
->send();
|
|
}
|
|
|
|
public function refreshVerificationStatus(): void
|
|
{
|
|
if ($this->managedTenant instanceof Tenant) {
|
|
$this->managedTenant->refresh();
|
|
}
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->onboardingSession->refresh();
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Verification refreshed')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $operationTypes
|
|
*/
|
|
public function startBootstrap(array $operationTypes): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMember($user);
|
|
$this->authorizeEditableDraft($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->verificationCanProceed()) {
|
|
Notification::make()
|
|
->title('Verification required')
|
|
->body('Complete verification for the selected provider connection before starting bootstrap actions.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($tenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
Notification::make()
|
|
->title('No provider connection selected')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$registry = app(ProviderOperationRegistry::class);
|
|
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== '')));
|
|
|
|
$types = array_values(array_filter(
|
|
$types,
|
|
static fn (string $type): bool => $type !== 'provider.connection.check' && $registry->isAllowed($type),
|
|
));
|
|
|
|
foreach ($types as $operationType) {
|
|
$capability = $this->resolveBootstrapCapability($operationType);
|
|
|
|
if ($capability === null) {
|
|
abort(422);
|
|
}
|
|
|
|
if (! $user->can($capability, $this->workspace)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
if (empty($types)) {
|
|
Notification::make()
|
|
->title('No bootstrap actions selected')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
/** @var array{status: 'started', runs: array<string, int>, created: array<string, bool>}|array{status: 'scope_busy', run: OperationRun} $result */
|
|
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
|
|
$lockedConnection = ProviderConnection::query()
|
|
->whereKey($connection->getKey())
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
$activeRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->active()
|
|
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
if ($activeRun instanceof OperationRun) {
|
|
return [
|
|
'status' => 'scope_busy',
|
|
'run' => $activeRun,
|
|
];
|
|
}
|
|
|
|
$runsService = app(OperationRunService::class);
|
|
|
|
$bootstrapRuns = [];
|
|
$bootstrapCreated = [];
|
|
|
|
foreach ($types as $operationType) {
|
|
$definition = $registry->get($operationType);
|
|
|
|
$context = [
|
|
'wizard' => [
|
|
'flow' => 'managed_tenant_onboarding',
|
|
'step' => 'bootstrap',
|
|
],
|
|
'provider' => $lockedConnection->provider,
|
|
'module' => $definition['module'],
|
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
'target_scope' => [
|
|
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
|
|
],
|
|
];
|
|
|
|
$run = $runsService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: $operationType,
|
|
identityInputs: [
|
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
],
|
|
context: $context,
|
|
initiator: $user,
|
|
);
|
|
|
|
if ($run->wasRecentlyCreated) {
|
|
$this->dispatchBootstrapJob(
|
|
operationType: $operationType,
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $lockedConnection->getKey(),
|
|
run: $run,
|
|
);
|
|
}
|
|
|
|
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
|
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
|
|
}
|
|
|
|
return [
|
|
'status' => 'started',
|
|
'runs' => $bootstrapRuns,
|
|
'created' => $bootstrapCreated,
|
|
];
|
|
});
|
|
|
|
if ($result['status'] === 'scope_busy') {
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
Notification::make()
|
|
->title('Another operation is already running')
|
|
->body('Please wait for the active run to finish.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$bootstrapRuns = $result['runs'];
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$state = $this->onboardingSession->state ?? [];
|
|
|
|
$existing = $state['bootstrap_operation_runs'] ?? [];
|
|
$existing = is_array($existing) ? $existing : [];
|
|
|
|
$state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns);
|
|
$state['bootstrap_operation_types'] = $types;
|
|
|
|
$this->onboardingSession->state = $state;
|
|
$this->onboardingSession->current_step = 'bootstrap';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
|
'operation_types' => $types,
|
|
'operation_run_ids' => $bootstrapRuns,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $this->onboardingSession->getKey(),
|
|
);
|
|
}
|
|
|
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
|
|
foreach ($types as $operationType) {
|
|
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
|
|
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
|
|
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
|
|
|
|
$toast = $wasCreated
|
|
? OperationUxPresenter::queuedToast($operationType)
|
|
: OperationUxPresenter::alreadyQueuedToast($operationType);
|
|
|
|
if ($runUrl !== null) {
|
|
$toast->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url($runUrl),
|
|
]);
|
|
}
|
|
|
|
$toast->send();
|
|
}
|
|
}
|
|
|
|
private function dispatchBootstrapJob(
|
|
string $operationType,
|
|
int $tenantId,
|
|
int $userId,
|
|
int $providerConnectionId,
|
|
OperationRun $run,
|
|
): void {
|
|
match ($operationType) {
|
|
'inventory_sync' => ProviderInventorySyncJob::dispatch(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
providerConnectionId: $providerConnectionId,
|
|
operationRun: $run,
|
|
),
|
|
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
providerConnectionId: $providerConnectionId,
|
|
operationRun: $run,
|
|
),
|
|
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
|
|
};
|
|
}
|
|
|
|
private function resolveBootstrapCapability(string $operationType): ?string
|
|
{
|
|
return match ($operationType) {
|
|
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
|
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function canStartAnyBootstrap(): bool
|
|
{
|
|
return $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC)
|
|
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC)
|
|
|| $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_BACKUP_BOOTSTRAP);
|
|
}
|
|
|
|
private function currentUserCan(string $capability): bool
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
return false;
|
|
}
|
|
|
|
return $user->can($capability, $this->workspace);
|
|
}
|
|
|
|
private function tenantlessOperationRunUrl(int $runId): string
|
|
{
|
|
return OperationRunLinks::tenantlessView($runId);
|
|
}
|
|
|
|
public function verificationSucceeded(): bool
|
|
{
|
|
return $this->verificationHasSucceeded();
|
|
}
|
|
|
|
private function verificationCanProceed(): bool
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
if ($run->status !== OperationRunStatus::Completed->value) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->verificationRunMatchesSelectedConnection($run)) {
|
|
return false;
|
|
}
|
|
|
|
return in_array($this->verificationStatus(), ['ready', 'needs_attention'], true);
|
|
}
|
|
|
|
private function verificationIsBlocked(): bool
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
if ($run->status !== OperationRunStatus::Completed->value) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->verificationRunMatchesSelectedConnection($run)) {
|
|
return false;
|
|
}
|
|
|
|
return $this->verificationStatus() === 'blocked';
|
|
}
|
|
|
|
private function canCompleteOnboarding(): bool
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->verificationCanProceed()) {
|
|
return true;
|
|
}
|
|
|
|
if (! (bool) ($this->data['override_blocked'] ?? false)) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->verificationIsBlocked()) {
|
|
return false;
|
|
}
|
|
|
|
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
|
|
}
|
|
|
|
private function completionSummaryTenantLine(): string
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return '—';
|
|
}
|
|
|
|
$name = $this->managedTenant->name ?? '—';
|
|
$tenantId = $this->managedTenant->graphTenantId();
|
|
|
|
return $tenantId !== null ? "{$name} ({$tenantId})" : $name;
|
|
}
|
|
|
|
private function completionSummaryConnectionLabel(): string
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return '—';
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return 'Not configured';
|
|
}
|
|
|
|
$type = $connection->connection_type instanceof ProviderConnectionType
|
|
? $connection->connection_type
|
|
: ProviderConnectionType::tryFrom((string) $connection->connection_type);
|
|
|
|
return match ($type) {
|
|
ProviderConnectionType::Platform => 'Platform',
|
|
ProviderConnectionType::Dedicated => 'Dedicated',
|
|
default => (string) ($connection->display_name ?? 'Unknown'),
|
|
};
|
|
}
|
|
|
|
private function completionSummaryConnectionDetail(): string
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return '';
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($this->managedTenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
return '';
|
|
}
|
|
|
|
$consentStatus = $connection->consent_status instanceof ProviderConsentStatus
|
|
? $connection->consent_status
|
|
: ProviderConsentStatus::tryFrom((string) $connection->consent_status);
|
|
|
|
$consentLabel = match ($consentStatus) {
|
|
ProviderConsentStatus::Granted => 'Consent granted',
|
|
ProviderConsentStatus::Failed => 'Consent failed',
|
|
ProviderConsentStatus::Required => 'Consent required',
|
|
ProviderConsentStatus::Revoked => 'Consent revoked',
|
|
default => 'Consent unknown',
|
|
};
|
|
|
|
$parts = [$connection->display_name ?? '', $consentLabel];
|
|
|
|
return implode(' · ', array_filter($parts, static fn (string $v): bool => $v !== ''));
|
|
}
|
|
|
|
private function completionSummaryConnectionSummary(): string
|
|
{
|
|
$label = $this->completionSummaryConnectionLabel();
|
|
$detail = $this->completionSummaryConnectionDetail();
|
|
|
|
if ($detail === '') {
|
|
return $label;
|
|
}
|
|
|
|
return sprintf('%s - %s', $label, $detail);
|
|
}
|
|
|
|
private function completionSummaryVerificationDetail(): string
|
|
{
|
|
$counts = $this->verificationReportCounts();
|
|
|
|
if ($counts === null) {
|
|
return 'Not started';
|
|
}
|
|
|
|
return sprintf('%d/%d checks passed', $counts['pass'], $counts['total']);
|
|
}
|
|
|
|
private function completionSummaryBootstrapLabel(): string
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return 'Skipped';
|
|
}
|
|
|
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
$runs = is_array($runs) ? $runs : [];
|
|
|
|
if ($runs === []) {
|
|
return 'Skipped';
|
|
}
|
|
|
|
return 'Started';
|
|
}
|
|
|
|
private function completionSummaryBootstrapDetail(): string
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return 'No bootstrap actions selected';
|
|
}
|
|
|
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
$runs = is_array($runs) ? $runs : [];
|
|
|
|
if ($runs === []) {
|
|
return 'No bootstrap actions selected';
|
|
}
|
|
|
|
return sprintf('%d operation run(s) started', count($runs));
|
|
}
|
|
|
|
private function completionSummaryBootstrapSummary(): string
|
|
{
|
|
return sprintf(
|
|
'%s - %s',
|
|
$this->completionSummaryBootstrapLabel(),
|
|
$this->completionSummaryBootstrapDetail(),
|
|
);
|
|
}
|
|
|
|
private function completionSummaryBootstrapColor(): string
|
|
{
|
|
return $this->completionSummaryBootstrapLabel() === 'Started'
|
|
? 'info'
|
|
: 'gray';
|
|
}
|
|
|
|
public function completeOnboarding(): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE);
|
|
$this->authorizeEditableDraft($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
abort(404);
|
|
}
|
|
|
|
$run = $this->verificationRun();
|
|
$verificationSucceeded = $this->verificationHasSucceeded();
|
|
$verificationCanProceed = $this->verificationCanProceed();
|
|
$verificationBlocked = $this->verificationIsBlocked();
|
|
|
|
if (! $verificationCanProceed) {
|
|
$overrideBlocked = (bool) ($this->data['override_blocked'] ?? false);
|
|
|
|
if (! $overrideBlocked) {
|
|
Notification::make()
|
|
->title('Verification required')
|
|
->body('Complete verification for the selected provider connection before finishing onboarding.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $verificationBlocked) {
|
|
throw ValidationException::withMessages([
|
|
'data.override_blocked' => 'Verification override is only allowed when verification is blocked.',
|
|
]);
|
|
}
|
|
|
|
$overrideReason = trim((string) ($this->data['override_reason'] ?? ''));
|
|
|
|
if ($overrideReason === '') {
|
|
throw ValidationException::withMessages([
|
|
'data.override_reason' => 'Override reason is required.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($tenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
Notification::make()
|
|
->title('Provider connection required')
|
|
->body('Create or select a provider connection before finishing onboarding.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$overrideBlocked = (bool) ($this->data['override_blocked'] ?? false);
|
|
$overrideReason = trim((string) ($this->data['override_reason'] ?? ''));
|
|
|
|
DB::transaction(function () use ($tenant, $user): void {
|
|
$tenant->update(['status' => Tenant::STATUS_ACTIVE]);
|
|
|
|
$this->onboardingSession->forceFill([
|
|
'completed_at' => now(),
|
|
'cancelled_at' => null,
|
|
'current_step' => 'complete',
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
])->save();
|
|
});
|
|
|
|
if ($overrideBlocked) {
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingActivationOverrideUsed->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
|
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
|
|
'override_reason' => $overrideReason,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'override',
|
|
resourceType: 'managed_tenant_onboarding_session',
|
|
resourceId: (string) $this->onboardingSession->getKey(),
|
|
);
|
|
}
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingActivation->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
|
|
'verification_succeeded' => $verificationSucceeded,
|
|
'override_blocked' => $overrideBlocked,
|
|
'override_reason' => $overrideBlocked ? $overrideReason : null,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: $overrideBlocked ? 'override' : 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
|
|
$this->redirect(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant));
|
|
}
|
|
|
|
private function verificationRun(): ?OperationRun
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return null;
|
|
}
|
|
|
|
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
|
|
|
|
if (! is_int($runId)) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRun::query()
|
|
->where('tenant_id', (int) $this->managedTenant->getKey())
|
|
->whereKey($runId)
|
|
->first();
|
|
}
|
|
|
|
private function verificationHasSucceeded(): bool
|
|
{
|
|
return $this->verificationCanProceed()
|
|
&& $this->verificationStatus() === 'ready';
|
|
}
|
|
|
|
private function verificationRunIsStaleForSelectedConnection(): bool
|
|
{
|
|
$run = $this->verificationRun();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
return ! $this->verificationRunMatchesSelectedConnection($run);
|
|
}
|
|
|
|
private function verificationRunMatchesSelectedConnection(OperationRun $run): bool
|
|
{
|
|
$selectedProviderConnectionId = $this->selectedProviderConnectionId;
|
|
|
|
if ($selectedProviderConnectionId === null && $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
|
$selectedProviderConnectionId = is_int($candidate)
|
|
? $candidate
|
|
: (is_numeric($candidate) ? (int) $candidate : null);
|
|
}
|
|
|
|
if ($selectedProviderConnectionId === null) {
|
|
return false;
|
|
}
|
|
|
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
|
$runProviderConnectionId = $context['provider_connection_id'] ?? null;
|
|
$runProviderConnectionId = is_int($runProviderConnectionId)
|
|
? $runProviderConnectionId
|
|
: (is_numeric($runProviderConnectionId) ? (int) $runProviderConnectionId : null);
|
|
|
|
if ($runProviderConnectionId === null) {
|
|
return false;
|
|
}
|
|
|
|
return $runProviderConnectionId === $selectedProviderConnectionId;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $state
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function resetDependentOnboardingStateOnConnectionChange(array $state, ?int $previousProviderConnectionId, int $newProviderConnectionId): array
|
|
{
|
|
if ($previousProviderConnectionId === null) {
|
|
return $state;
|
|
}
|
|
|
|
if ($previousProviderConnectionId === $newProviderConnectionId) {
|
|
return $state;
|
|
}
|
|
|
|
unset(
|
|
$state['verification_operation_run_id'],
|
|
$state['bootstrap_operation_runs'],
|
|
$state['bootstrap_operation_types'],
|
|
);
|
|
|
|
$state['connection_recently_updated'] = true;
|
|
|
|
return $state;
|
|
}
|
|
|
|
private function connectionRecentlyUpdated(): bool
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return false;
|
|
}
|
|
|
|
return (bool) ($this->onboardingSession->state['connection_recently_updated'] ?? false);
|
|
}
|
|
|
|
/**
|
|
* @return array{display_name: string, entra_tenant_id: string, uses_dedicated_override: bool, client_id: string}
|
|
*/
|
|
private function inlineEditSelectedConnectionFill(int $providerConnectionId): array
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = ProviderConnection::query()
|
|
->with('credential')
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', (int) $this->managedTenant->getKey())
|
|
->whereKey($providerConnectionId)
|
|
->first();
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
abort(404);
|
|
}
|
|
|
|
$payload = $connection->credential?->payload;
|
|
$payload = is_array($payload) ? $payload : [];
|
|
|
|
return [
|
|
'display_name' => (string) $connection->display_name,
|
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
'uses_dedicated_override' => $connection->connection_type === ProviderConnectionType::Dedicated,
|
|
'client_id' => (string) ($payload['client_id'] ?? ''),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array{display_name?: mixed, connection_type?: mixed, uses_dedicated_override?: mixed, client_id?: mixed, client_secret?: mixed} $data
|
|
*/
|
|
public function updateSelectedProviderConnectionInline(int $providerConnectionId, array $data): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = ProviderConnection::query()
|
|
->with('credential')
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', (int) $this->managedTenant->getKey())
|
|
->whereKey($providerConnectionId)
|
|
->first();
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
abort(404);
|
|
}
|
|
|
|
$displayName = trim((string) ($data['display_name'] ?? ''));
|
|
$explicitConnectionType = ProviderConnectionType::tryFrom(trim((string) ($data['connection_type'] ?? '')));
|
|
$requestedDedicatedOverride = (bool) ($data['uses_dedicated_override'] ?? false);
|
|
$clientId = trim((string) ($data['client_id'] ?? ''));
|
|
$clientSecret = trim((string) ($data['client_secret'] ?? ''));
|
|
$existingType = $connection->connection_type instanceof ProviderConnectionType
|
|
? $connection->connection_type
|
|
: ProviderConnectionType::Platform;
|
|
|
|
if ($displayName === '') {
|
|
throw ValidationException::withMessages([
|
|
'display_name' => 'Connection name is required.',
|
|
]);
|
|
}
|
|
|
|
$existingPayload = $connection->credential?->payload;
|
|
$existingPayload = is_array($existingPayload) ? $existingPayload : [];
|
|
$existingClientId = trim((string) ($existingPayload['client_id'] ?? ''));
|
|
$targetType = $explicitConnectionType
|
|
?? (($requestedDedicatedOverride || $clientId !== '' || $clientSecret !== '' || $existingType === ProviderConnectionType::Dedicated)
|
|
? ProviderConnectionType::Dedicated
|
|
: ProviderConnectionType::Platform);
|
|
|
|
$changedFields = [];
|
|
|
|
if ($displayName !== (string) $connection->display_name) {
|
|
$changedFields[] = 'display_name';
|
|
}
|
|
|
|
if ($targetType !== $existingType) {
|
|
$changedFields[] = 'connection_type';
|
|
}
|
|
|
|
if ($targetType === ProviderConnectionType::Dedicated) {
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
|
|
|
if ($clientId === '') {
|
|
throw ValidationException::withMessages([
|
|
'client_id' => 'Dedicated App (client) ID is required.',
|
|
]);
|
|
}
|
|
|
|
if ($clientId !== $existingClientId) {
|
|
$changedFields[] = 'client_id';
|
|
}
|
|
|
|
if (($existingType !== ProviderConnectionType::Dedicated || $clientId !== $existingClientId) && $clientSecret === '') {
|
|
throw ValidationException::withMessages([
|
|
'client_secret' => 'Enter a dedicated client secret when enabling dedicated mode or changing the App (client) ID.',
|
|
]);
|
|
}
|
|
|
|
if ($clientSecret !== '') {
|
|
$changedFields[] = 'client_secret';
|
|
}
|
|
}
|
|
|
|
if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) {
|
|
$this->authorizeWorkspaceMutation($user, Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CONNECTION_MANAGE_DEDICATED);
|
|
}
|
|
|
|
DB::transaction(function () use ($connection, $displayName, $clientId, $clientSecret, $targetType, $existingType, $existingClientId): void {
|
|
$connection->forceFill([
|
|
'display_name' => $displayName,
|
|
])->save();
|
|
|
|
if ($targetType === ProviderConnectionType::Dedicated && $clientSecret !== '') {
|
|
app(ProviderConnectionMutationService::class)->enableDedicatedOverride(
|
|
connection: $connection->fresh(),
|
|
clientId: $clientId,
|
|
clientSecret: $clientSecret,
|
|
);
|
|
}
|
|
|
|
if ($targetType === ProviderConnectionType::Dedicated && $existingType === ProviderConnectionType::Dedicated && $clientSecret === '' && $clientId !== '' && $clientId === $existingClientId) {
|
|
return;
|
|
}
|
|
|
|
if ($targetType === ProviderConnectionType::Platform && $existingType === ProviderConnectionType::Dedicated) {
|
|
app(ProviderConnectionMutationService::class)->revertToPlatform($connection->fresh());
|
|
}
|
|
});
|
|
|
|
if (in_array('connection_type', $changedFields, true)) {
|
|
app(AuditLogger::class)->log(
|
|
tenant: $this->managedTenant,
|
|
action: 'provider_connection.connection_type_changed',
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'provider' => (string) $connection->provider,
|
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
'from_connection_type' => $existingType->value,
|
|
'to_connection_type' => $targetType->value,
|
|
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
|
],
|
|
],
|
|
actorId: (int) $user->getKey(),
|
|
actorEmail: (string) $user->email,
|
|
actorName: (string) $user->name,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
status: 'success',
|
|
);
|
|
}
|
|
|
|
if ($changedFields !== []) {
|
|
app(AuditLogger::class)->log(
|
|
tenant: $this->managedTenant,
|
|
action: 'provider_connection.updated',
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'provider' => (string) $connection->provider,
|
|
'entra_tenant_id' => (string) $connection->entra_tenant_id,
|
|
'fields' => $changedFields,
|
|
'connection_type' => $targetType->value,
|
|
'source' => 'managed_tenant_onboarding_wizard.inline_edit',
|
|
],
|
|
],
|
|
actorId: (int) $user->getKey(),
|
|
actorEmail: (string) $user->email,
|
|
actorName: (string) $user->name,
|
|
resourceType: 'provider_connection',
|
|
resourceId: (string) $connection->getKey(),
|
|
status: 'success',
|
|
);
|
|
}
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$state = is_array($this->onboardingSession->state) ? $this->onboardingSession->state : [];
|
|
|
|
unset(
|
|
$state['verification_operation_run_id'],
|
|
$state['bootstrap_operation_runs'],
|
|
$state['bootstrap_operation_types'],
|
|
);
|
|
|
|
$state['connection_recently_updated'] = true;
|
|
|
|
$this->onboardingSession->state = array_merge($state, [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
]);
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Connection updated')
|
|
->success()
|
|
->send();
|
|
|
|
$this->initializeWizardData();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function bootstrapOperationOptions(): array
|
|
{
|
|
$registry = app(ProviderOperationRegistry::class);
|
|
|
|
return collect($registry->all())
|
|
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check')
|
|
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
|
->all();
|
|
}
|
|
|
|
private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int
|
|
{
|
|
$id = ProviderConnection::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('is_default', true)
|
|
->orderByDesc('id')
|
|
->value('id');
|
|
|
|
if (is_int($id)) {
|
|
return $id;
|
|
}
|
|
|
|
$fallback = ProviderConnection::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->orderByDesc('id')
|
|
->value('id');
|
|
|
|
return is_int($fallback) ? $fallback : null;
|
|
}
|
|
|
|
private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderConnection
|
|
{
|
|
$providerConnectionId = $this->selectedProviderConnectionId;
|
|
|
|
if (! is_int($providerConnectionId) && $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
|
$providerConnectionId = is_int($candidate) ? $candidate : null;
|
|
}
|
|
|
|
if (! is_int($providerConnectionId)) {
|
|
$providerConnectionId = $this->resolveDefaultProviderConnectionId($tenant);
|
|
}
|
|
|
|
if (! is_int($providerConnectionId)) {
|
|
return null;
|
|
}
|
|
|
|
return ProviderConnection::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->whereKey($providerConnectionId)
|
|
->first();
|
|
}
|
|
}
|