Implements workspace-scoped managed tenant onboarding wizard (Filament v5 / Livewire v4) with strict RBAC (404/403 semantics), resumable sessions, provider connection selection/creation, verification OperationRun, and optional bootstrap. Removes legacy onboarding entrypoints and adds Pest coverage + spec artifacts (073). ## Summary <!-- Kurz: Was ändert sich und warum? --> ## Spec-Driven Development (SDD) - [ ] Es gibt eine Spec unter `specs/<NNN>-<feature>/` - [ ] Enthaltene Dateien: `plan.md`, `tasks.md`, `spec.md` - [ ] Spec beschreibt Verhalten/Acceptance Criteria (nicht nur Implementation) - [ ] Wenn sich Anforderungen während der Umsetzung geändert haben: Spec/Plan/Tasks wurden aktualisiert ## Implementation - [ ] Implementierung entspricht der Spec - [ ] Edge cases / Fehlerfälle berücksichtigt - [ ] Keine unbeabsichtigten Änderungen außerhalb des Scopes ## Tests - [ ] Tests ergänzt/aktualisiert (Pest/PHPUnit) - [ ] Relevante Tests lokal ausgeführt (`./vendor/bin/sail artisan test` oder `php artisan test`) ## Migration / Config / Ops (falls relevant) - [ ] Migration(en) enthalten und getestet - [ ] Rollback bedacht (rückwärts kompatibel, sichere Migration) - [ ] Neue Env Vars dokumentiert (`.env.example` / Doku) - [ ] Queue/cron/storage Auswirkungen geprüft ## UI (Filament/Livewire) (falls relevant) - [ ] UI-Flows geprüft - [ ] Screenshots/Notizen hinzugefügt ## Notes <!-- Links, Screenshots, Follow-ups, offene Punkte --> Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.fritz.box> Reviewed-on: #88
1245 lines
46 KiB
PHP
1245 lines
46 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Filament\Pages\Workspaces;
|
|
|
|
use App\Filament\Pages\TenantDashboard;
|
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
|
use App\Jobs\ProviderInventorySyncJob;
|
|
use App\Models\OperationRun;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\TenantOnboardingSession;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\TenantMembershipManager;
|
|
use App\Services\OperationRunService;
|
|
use App\Services\Providers\CredentialManager;
|
|
use App\Services\Providers\ProviderOperationRegistry;
|
|
use App\Services\Providers\ProviderOperationStartGate;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\CheckboxList;
|
|
use Filament\Forms\Components\Radio;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Forms\Components\Toggle;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Actions as SchemaActions;
|
|
use Filament\Schemas\Components\Section;
|
|
use Filament\Schemas\Components\Text;
|
|
use Filament\Schemas\Components\Utilities\Get;
|
|
use Filament\Schemas\Components\Wizard;
|
|
use Filament\Schemas\Components\Wizard\Step;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Enums\Width;
|
|
use Filament\Support\Exceptions\Halt;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use RuntimeException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class ManagedTenantOnboardingWizard extends Page
|
|
{
|
|
protected static string $layout = 'filament-panels::components.layout.simple';
|
|
|
|
protected Width|string|null $maxContentWidth = Width::ScreenExtraLarge;
|
|
|
|
protected static bool $shouldRegisterNavigation = false;
|
|
|
|
protected static bool $isDiscovered = false;
|
|
|
|
protected static ?string $title = 'Managed tenant onboarding';
|
|
|
|
public Workspace $workspace;
|
|
|
|
public ?Tenant $managedTenant = null;
|
|
|
|
public ?TenantOnboardingSession $onboardingSession = null;
|
|
|
|
public ?int $selectedProviderConnectionId = null;
|
|
|
|
/**
|
|
* Filament schema state.
|
|
*
|
|
* @var array<string, mixed>
|
|
*/
|
|
public array $data = [];
|
|
|
|
/**
|
|
* @var array<int, string>
|
|
*/
|
|
public array $selectedBootstrapOperationTypes = [];
|
|
|
|
/**
|
|
* @return array<Action>
|
|
*/
|
|
protected function getHeaderActions(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
public function mount(Workspace $workspace): void
|
|
{
|
|
$this->workspace = $workspace;
|
|
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! app(WorkspaceContext::class)->isMember($user, $workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $workspace)) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->resumeLatestOnboardingSessionIfUnambiguous();
|
|
|
|
$this->initializeWizardData();
|
|
}
|
|
|
|
public function content(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->statePath('data')
|
|
->schema([
|
|
Wizard::make([
|
|
Step::make('Identify managed tenant')
|
|
->description('Create or resume a pending managed tenant in this workspace.')
|
|
->schema([
|
|
Section::make('Tenant')
|
|
->schema([
|
|
TextInput::make('tenant_id')
|
|
->label('Tenant ID (GUID)')
|
|
->required()
|
|
->rules(['uuid'])
|
|
->maxLength(255),
|
|
TextInput::make('name')
|
|
->label('Display name')
|
|
->required()
|
|
->maxLength(255),
|
|
]),
|
|
])
|
|
->afterValidation(function (): void {
|
|
$tenantGuid = (string) ($this->data['tenant_id'] ?? '');
|
|
$tenantName = (string) ($this->data['name'] ?? '');
|
|
|
|
try {
|
|
$this->identifyManagedTenant([
|
|
'tenant_id' => $tenantGuid,
|
|
'name' => $tenantName,
|
|
]);
|
|
} catch (NotFoundHttpException) {
|
|
Notification::make()
|
|
->title('Tenant not available')
|
|
->body('This tenant cannot be onboarded in this workspace.')
|
|
->danger()
|
|
->send();
|
|
|
|
throw new Halt;
|
|
}
|
|
|
|
$this->initializeWizardData();
|
|
}),
|
|
|
|
Step::make('Provider connection')
|
|
->description('Select an existing connection or create a new one.')
|
|
->schema([
|
|
Section::make('Connection')
|
|
->schema([
|
|
Radio::make('connection_mode')
|
|
->label('Mode')
|
|
->options([
|
|
'existing' => 'Use existing connection',
|
|
'new' => 'Create new connection',
|
|
])
|
|
->required()
|
|
->default('existing')
|
|
->live(),
|
|
Select::make('provider_connection_id')
|
|
->label('Provider connection')
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'existing')
|
|
->options(fn (): array => $this->providerConnectionOptions())
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'existing'),
|
|
|
|
TextInput::make('new_connection.display_name')
|
|
->label('Display name')
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->maxLength(255)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
|
TextInput::make('new_connection.client_id')
|
|
->label('Client ID')
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->maxLength(255)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
|
TextInput::make('new_connection.client_secret')
|
|
->label('Client secret')
|
|
->password()
|
|
->required(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->maxLength(255)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new')
|
|
->helperText('Stored encrypted and never shown again.'),
|
|
Toggle::make('new_connection.is_default')
|
|
->label('Make default')
|
|
->default(true)
|
|
->visible(fn (Get $get): bool => $get('connection_mode') === 'new'),
|
|
]),
|
|
])
|
|
->afterValidation(function (): void {
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
throw new Halt;
|
|
}
|
|
|
|
$mode = (string) ($this->data['connection_mode'] ?? 'existing');
|
|
|
|
if ($mode === 'new') {
|
|
$new = is_array($this->data['new_connection'] ?? null) ? $this->data['new_connection'] : [];
|
|
|
|
$this->createProviderConnection([
|
|
'display_name' => (string) ($new['display_name'] ?? ''),
|
|
'client_id' => (string) ($new['client_id'] ?? ''),
|
|
'client_secret' => (string) ($new['client_secret'] ?? ''),
|
|
'is_default' => (bool) ($new['is_default'] ?? true),
|
|
]);
|
|
|
|
if (is_array($this->data['new_connection'] ?? null)) {
|
|
$this->data['new_connection']['client_secret'] = null;
|
|
}
|
|
} else {
|
|
$providerConnectionId = (int) ($this->data['provider_connection_id'] ?? 0);
|
|
|
|
if ($providerConnectionId <= 0) {
|
|
throw new Halt;
|
|
}
|
|
|
|
$this->selectProviderConnection($providerConnectionId);
|
|
}
|
|
|
|
$this->touchOnboardingSessionStep('connection');
|
|
$this->initializeWizardData();
|
|
}),
|
|
|
|
Step::make('Verify access')
|
|
->description('Run a queued verification check (Operation Run).')
|
|
->schema([
|
|
Section::make('Verification')
|
|
->schema([
|
|
Text::make(fn (): string => 'Status: '.$this->verificationStatusLabel())
|
|
->badge()
|
|
->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'),
|
|
SchemaActions::make([
|
|
Action::make('wizardStartVerification')
|
|
->label('Start verification')
|
|
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
|
->action(fn () => $this->startVerification()),
|
|
Action::make('wizardViewVerificationRun')
|
|
->label('View run')
|
|
->url(fn (): ?string => $this->verificationRunUrl())
|
|
->visible(fn (): bool => $this->verificationRunUrl() !== null),
|
|
]),
|
|
]),
|
|
])
|
|
->beforeValidation(function (): void {
|
|
if (! $this->verificationHasSucceeded()) {
|
|
Notification::make()
|
|
->title('Verification required')
|
|
->body('Run verification successfully before continuing.')
|
|
->warning()
|
|
->send();
|
|
|
|
throw new Halt;
|
|
}
|
|
|
|
$this->touchOnboardingSessionStep('verify');
|
|
}),
|
|
|
|
Step::make('Bootstrap (optional)')
|
|
->description('Optionally start inventory and compliance operations.')
|
|
->schema([
|
|
Section::make('Bootstrap')
|
|
->schema([
|
|
CheckboxList::make('bootstrap_operation_types')
|
|
->label('Bootstrap actions')
|
|
->options(fn (): array => $this->bootstrapOperationOptions())
|
|
->columns(1),
|
|
SchemaActions::make([
|
|
Action::make('wizardStartBootstrap')
|
|
->label('Start bootstrap')
|
|
->visible(fn (): bool => $this->managedTenant instanceof Tenant)
|
|
->action(fn () => $this->startBootstrap((array) ($this->data['bootstrap_operation_types'] ?? []))),
|
|
]),
|
|
Text::make(fn (): string => $this->bootstrapRunsLabel())
|
|
->hidden(fn (): bool => $this->bootstrapRunsLabel() === ''),
|
|
]),
|
|
])
|
|
->afterValidation(function (): void {
|
|
$types = $this->data['bootstrap_operation_types'] ?? [];
|
|
$this->selectedBootstrapOperationTypes = is_array($types)
|
|
? array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''))
|
|
: [];
|
|
|
|
$this->touchOnboardingSessionStep('bootstrap');
|
|
}),
|
|
|
|
Step::make('Complete')
|
|
->description('Activate the tenant and finish onboarding.')
|
|
->schema([
|
|
Section::make('Finish')
|
|
->schema([
|
|
Text::make(fn (): string => $this->managedTenant instanceof Tenant
|
|
? 'Tenant: '.$this->managedTenant->name
|
|
: 'Tenant: not selected')
|
|
->badge()
|
|
->color('gray'),
|
|
Text::make(fn (): string => 'Verification: '.($this->verificationHasSucceeded() ? 'succeeded' : 'not yet succeeded'))
|
|
->badge()
|
|
->color(fn (): string => $this->verificationHasSucceeded() ? 'success' : 'warning'),
|
|
SchemaActions::make([
|
|
Action::make('wizardCompleteOnboarding')
|
|
->label('Complete onboarding')
|
|
->color('success')
|
|
->disabled(fn (): bool => ! $this->verificationHasSucceeded())
|
|
->action(fn () => $this->completeOnboarding()),
|
|
]),
|
|
]),
|
|
])
|
|
->beforeValidation(function (): void {
|
|
if (! $this->verificationHasSucceeded()) {
|
|
throw new Halt;
|
|
}
|
|
}),
|
|
])
|
|
->startOnStep(fn (): int => $this->computeWizardStartStep())
|
|
->skippable(false),
|
|
]);
|
|
}
|
|
|
|
private function resumeLatestOnboardingSessionIfUnambiguous(): void
|
|
{
|
|
$sessionCount = TenantOnboardingSession::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->whereNull('completed_at')
|
|
->count();
|
|
|
|
if ($sessionCount !== 1) {
|
|
return;
|
|
}
|
|
|
|
$session = TenantOnboardingSession::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->whereNull('completed_at')
|
|
->orderByDesc('updated_at')
|
|
->first();
|
|
|
|
if (! $session instanceof TenantOnboardingSession) {
|
|
return;
|
|
}
|
|
|
|
$tenant = Tenant::query()
|
|
->where('workspace_id', (int) $this->workspace->getKey())
|
|
->whereKey((int) $session->tenant_id)
|
|
->first();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
return;
|
|
}
|
|
|
|
$this->managedTenant = $tenant;
|
|
$this->onboardingSession = $session;
|
|
|
|
$providerConnectionId = $session->state['provider_connection_id'] ?? null;
|
|
$this->selectedProviderConnectionId = is_int($providerConnectionId)
|
|
? $providerConnectionId
|
|
: $this->resolveDefaultProviderConnectionId($tenant);
|
|
|
|
$bootstrapTypes = $session->state['bootstrap_operation_types'] ?? [];
|
|
$this->selectedBootstrapOperationTypes = is_array($bootstrapTypes)
|
|
? array_values(array_filter($bootstrapTypes, static fn ($v): bool => is_string($v) && $v !== ''))
|
|
: [];
|
|
}
|
|
|
|
private function initializeWizardData(): void
|
|
{
|
|
if (! array_key_exists('connection_mode', $this->data)) {
|
|
$this->data['connection_mode'] = 'existing';
|
|
}
|
|
|
|
if ($this->managedTenant instanceof Tenant) {
|
|
$this->data['tenant_id'] ??= (string) $this->managedTenant->tenant_id;
|
|
$this->data['name'] ??= (string) $this->managedTenant->name;
|
|
}
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$providerConnectionId = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
|
if (is_int($providerConnectionId)) {
|
|
$this->data['provider_connection_id'] = $providerConnectionId;
|
|
$this->selectedProviderConnectionId = $providerConnectionId;
|
|
}
|
|
|
|
$types = $this->onboardingSession->state['bootstrap_operation_types'] ?? null;
|
|
if (is_array($types)) {
|
|
$this->data['bootstrap_operation_types'] = array_values(array_filter($types, static fn ($v): bool => is_string($v) && $v !== ''));
|
|
}
|
|
}
|
|
|
|
if (($this->data['provider_connection_id'] ?? null) === null && $this->selectedProviderConnectionId !== null) {
|
|
$this->data['provider_connection_id'] = $this->selectedProviderConnectionId;
|
|
}
|
|
}
|
|
|
|
private function computeWizardStartStep(): int
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return 1;
|
|
}
|
|
|
|
if (! $this->resolveSelectedProviderConnection($this->managedTenant)) {
|
|
return 2;
|
|
}
|
|
|
|
if (! $this->verificationHasSucceeded()) {
|
|
return 3;
|
|
}
|
|
|
|
return 4;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function providerConnectionOptions(): array
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return [];
|
|
}
|
|
|
|
return ProviderConnection::query()
|
|
->where('tenant_id', $this->managedTenant->getKey())
|
|
->orderByDesc('is_default')
|
|
->orderBy('display_name')
|
|
->pluck('display_name', 'id')
|
|
->all();
|
|
}
|
|
|
|
private function verificationStatusLabel(): string
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return 'not started';
|
|
}
|
|
|
|
if ($this->verificationHasSucceeded()) {
|
|
return 'succeeded';
|
|
}
|
|
|
|
$runId = $this->onboardingSession?->state['verification_operation_run_id'] ?? null;
|
|
|
|
return is_int($runId) ? 'running or failed' : 'not started';
|
|
}
|
|
|
|
private function verificationRunUrl(): ?string
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return null;
|
|
}
|
|
|
|
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
|
|
|
|
if (! is_int($runId)) {
|
|
return null;
|
|
}
|
|
|
|
return OperationRunLinks::view($runId, $this->managedTenant);
|
|
}
|
|
|
|
private function bootstrapRunsLabel(): string
|
|
{
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return '';
|
|
}
|
|
|
|
$runs = $this->onboardingSession->state['bootstrap_operation_runs'] ?? null;
|
|
$runs = is_array($runs) ? $runs : [];
|
|
|
|
if ($runs === []) {
|
|
return '';
|
|
}
|
|
|
|
return sprintf('Started %d bootstrap run(s).', count($runs));
|
|
}
|
|
|
|
private function touchOnboardingSessionStep(string $step): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return;
|
|
}
|
|
|
|
$this->onboardingSession->forceFill([
|
|
'current_step' => $step,
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
])->save();
|
|
}
|
|
|
|
private function authorizeWorkspaceMutation(User $user): void
|
|
{
|
|
if (! app(WorkspaceContext::class)->isMember($user, $this->workspace)) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $user->can(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD, $this->workspace)) {
|
|
abort(403);
|
|
}
|
|
}
|
|
|
|
private function resolveWorkspaceIdForUnboundTenant(Tenant $tenant): ?int
|
|
{
|
|
$workspaceId = DB::table('tenant_memberships')
|
|
->join('workspace_memberships', 'workspace_memberships.user_id', '=', 'tenant_memberships.user_id')
|
|
->where('tenant_memberships.tenant_id', (int) $tenant->getKey())
|
|
->orderByRaw("CASE tenant_memberships.role WHEN 'owner' THEN 0 WHEN 'manager' THEN 1 WHEN 'operator' THEN 2 ELSE 3 END")
|
|
->value('workspace_memberships.workspace_id');
|
|
|
|
return $workspaceId === null ? null : (int) $workspaceId;
|
|
}
|
|
|
|
/**
|
|
* @param array{tenant_id: string, name: string} $data
|
|
*/
|
|
public function identifyManagedTenant(array $data): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user);
|
|
|
|
$tenantGuid = $data['tenant_id'];
|
|
$tenantName = $data['name'];
|
|
|
|
DB::transaction(function () use ($user, $tenantGuid, $tenantName): void {
|
|
$auditLogger = app(WorkspaceAuditLogger::class);
|
|
$membershipManager = app(TenantMembershipManager::class);
|
|
|
|
$existingTenant = Tenant::query()
|
|
->withTrashed()
|
|
->where('tenant_id', $tenantGuid)
|
|
->first();
|
|
|
|
if ($existingTenant instanceof Tenant) {
|
|
if ($existingTenant->trashed() || $existingTenant->status === 'archived') {
|
|
abort(404);
|
|
}
|
|
|
|
if ($existingTenant->workspace_id === null) {
|
|
$resolvedWorkspaceId = $this->resolveWorkspaceIdForUnboundTenant($existingTenant);
|
|
|
|
if ($resolvedWorkspaceId === (int) $this->workspace->getKey()) {
|
|
$existingTenant->forceFill(['workspace_id' => $resolvedWorkspaceId])->save();
|
|
}
|
|
}
|
|
|
|
if ((int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
if ($existingTenant->name !== $tenantName) {
|
|
$existingTenant->forceFill(['name' => $tenantName])->save();
|
|
}
|
|
|
|
$tenant = $existingTenant;
|
|
} else {
|
|
try {
|
|
$tenant = Tenant::query()->create([
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'name' => $tenantName,
|
|
'tenant_id' => $tenantGuid,
|
|
'environment' => 'other',
|
|
'status' => 'pending',
|
|
]);
|
|
} catch (QueryException $exception) {
|
|
// Race-safe global uniqueness: if another workspace created the tenant_id first,
|
|
// treat it as deny-as-not-found.
|
|
$existingTenant = Tenant::query()
|
|
->withTrashed()
|
|
->where('tenant_id', $tenantGuid)
|
|
->first();
|
|
|
|
if ($existingTenant instanceof Tenant && (int) $existingTenant->workspace_id !== (int) $this->workspace->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
throw $exception;
|
|
}
|
|
}
|
|
|
|
$membershipManager->addMember(
|
|
tenant: $tenant,
|
|
actor: $user,
|
|
member: $user,
|
|
role: 'owner',
|
|
source: 'manual',
|
|
);
|
|
|
|
$ownerCount = TenantMembership::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->where('role', 'owner')
|
|
->count();
|
|
|
|
if ($ownerCount === 0) {
|
|
throw new RuntimeException('Tenant must have at least one owner.');
|
|
}
|
|
|
|
$session = TenantOnboardingSession::query()
|
|
->where('workspace_id', $this->workspace->getKey())
|
|
->where('tenant_id', $tenant->getKey())
|
|
->first();
|
|
|
|
$sessionWasCreated = false;
|
|
|
|
if (! $session instanceof TenantOnboardingSession) {
|
|
$session = new TenantOnboardingSession;
|
|
$session->workspace_id = (int) $this->workspace->getKey();
|
|
$session->tenant_id = (int) $tenant->getKey();
|
|
$session->started_by_user_id = (int) $user->getKey();
|
|
$sessionWasCreated = true;
|
|
}
|
|
|
|
$session->current_step = 'identify';
|
|
$session->state = array_merge($session->state ?? [], [
|
|
'tenant_id' => $tenantGuid,
|
|
]);
|
|
$session->updated_by_user_id = (int) $user->getKey();
|
|
$session->save();
|
|
|
|
$this->selectedProviderConnectionId ??= $this->resolveDefaultProviderConnectionId($tenant);
|
|
|
|
if ($this->selectedProviderConnectionId !== null) {
|
|
$session->state = array_merge($session->state ?? [], [
|
|
'provider_connection_id' => (int) $this->selectedProviderConnectionId,
|
|
]);
|
|
$session->save();
|
|
}
|
|
|
|
$auditLogger->log(
|
|
workspace: $this->workspace,
|
|
action: ($sessionWasCreated
|
|
? AuditActionId::ManagedTenantOnboardingStart
|
|
: AuditActionId::ManagedTenantOnboardingResume
|
|
)->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'tenant_guid' => $tenantGuid,
|
|
'tenant_name' => $tenantName,
|
|
'onboarding_session_id' => (int) $session->getKey(),
|
|
'current_step' => (string) $session->current_step,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
|
|
$this->managedTenant = $tenant;
|
|
$this->onboardingSession = $session;
|
|
});
|
|
|
|
Notification::make()
|
|
->title('Managed tenant identified')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function selectProviderConnection(int $providerConnectionId): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = ProviderConnection::query()
|
|
->where('tenant_id', $this->managedTenant->getKey())
|
|
->whereKey($providerConnectionId)
|
|
->first();
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
abort(404);
|
|
}
|
|
|
|
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
]);
|
|
$this->onboardingSession->current_step = 'connection';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Provider connection selected')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array{display_name: string, client_id: string, client_secret: string, is_default?: bool} $data
|
|
*/
|
|
public function createProviderConnection(array $data): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if ((int) $tenant->workspace_id !== (int) $this->workspace->getKey()) {
|
|
abort(404);
|
|
}
|
|
|
|
$displayName = trim((string) ($data['display_name'] ?? ''));
|
|
$clientId = (string) ($data['client_id'] ?? '');
|
|
$clientSecret = (string) ($data['client_secret'] ?? '');
|
|
$makeDefault = (bool) ($data['is_default'] ?? false);
|
|
|
|
if ($displayName === '') {
|
|
abort(422);
|
|
}
|
|
|
|
/** @var ProviderConnection $connection */
|
|
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault): ProviderConnection {
|
|
$connection = ProviderConnection::query()->updateOrCreate(
|
|
[
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'provider' => 'microsoft',
|
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
],
|
|
[
|
|
'display_name' => $displayName,
|
|
],
|
|
);
|
|
|
|
app(CredentialManager::class)->upsertClientSecretCredential(
|
|
connection: $connection,
|
|
clientId: $clientId,
|
|
clientSecret: $clientSecret,
|
|
);
|
|
|
|
if ($makeDefault) {
|
|
$connection->makeDefault();
|
|
}
|
|
|
|
return $connection;
|
|
});
|
|
|
|
$this->selectedProviderConnectionId = (int) $connection->getKey();
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
]);
|
|
$this->onboardingSession->current_step = 'connection';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Provider connection created')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
public function startVerification(): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
Notification::make()
|
|
->title('Identify a managed tenant first')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($tenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
Notification::make()
|
|
->title('No provider connection selected')
|
|
->body('Create or select a provider connection first.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$result = app(ProviderOperationStartGate::class)->start(
|
|
tenant: $tenant,
|
|
connection: $connection,
|
|
operationType: 'provider.connection.check',
|
|
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection): void {
|
|
ProviderConnectionHealthCheckJob::dispatch(
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $connection->getKey(),
|
|
operationRun: $run,
|
|
);
|
|
},
|
|
initiator: $user,
|
|
extraContext: [
|
|
'wizard' => [
|
|
'flow' => 'managed_tenant_onboarding',
|
|
'step' => 'verification',
|
|
],
|
|
],
|
|
);
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$this->onboardingSession->state = array_merge($this->onboardingSession->state ?? [], [
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'verification_operation_run_id' => (int) $result->run->getKey(),
|
|
]);
|
|
$this->onboardingSession->current_step = 'verify';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
$auditStatus = match ($result->status) {
|
|
'started' => 'success',
|
|
'deduped' => 'deduped',
|
|
'scope_busy' => 'blocked',
|
|
default => 'success',
|
|
};
|
|
|
|
app(WorkspaceAuditLogger::class)->log(
|
|
workspace: $this->workspace,
|
|
action: AuditActionId::ManagedTenantOnboardingVerificationStart->value,
|
|
context: [
|
|
'metadata' => [
|
|
'workspace_id' => (int) $this->workspace->getKey(),
|
|
'tenant_db_id' => (int) $tenant->getKey(),
|
|
'provider_connection_id' => (int) $connection->getKey(),
|
|
'operation_run_id' => (int) $result->run->getKey(),
|
|
'result' => (string) $result->status,
|
|
],
|
|
],
|
|
actor: $user,
|
|
status: $auditStatus,
|
|
resourceType: 'operation_run',
|
|
resourceId: (string) $result->run->getKey(),
|
|
);
|
|
|
|
if ($result->status === 'scope_busy') {
|
|
Notification::make()
|
|
->title('Another operation is already running')
|
|
->body('Please wait for the active run to finish.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
Notification::make()
|
|
->title($result->status === 'deduped' ? 'Verification already running' : 'Verification started')
|
|
->success()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
])
|
|
->send();
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $operationTypes
|
|
*/
|
|
public function startBootstrap(array $operationTypes): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->verificationHasSucceeded()) {
|
|
Notification::make()
|
|
->title('Verification required')
|
|
->body('Run verification successfully before starting bootstrap actions.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$connection = $this->resolveSelectedProviderConnection($tenant);
|
|
|
|
if (! $connection instanceof ProviderConnection) {
|
|
Notification::make()
|
|
->title('No provider connection selected')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$registry = app(ProviderOperationRegistry::class);
|
|
$types = array_values(array_unique(array_filter($operationTypes, static fn ($v): bool => is_string($v) && trim($v) !== '')));
|
|
|
|
$types = array_values(array_filter(
|
|
$types,
|
|
static fn (string $type): bool => $type !== 'provider.connection.check' && $registry->isAllowed($type),
|
|
));
|
|
|
|
if (empty($types)) {
|
|
Notification::make()
|
|
->title('No bootstrap actions selected')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
/** @var array{status: 'started', runs: array<string, int>}|array{status: 'scope_busy', run: OperationRun} $result */
|
|
$result = DB::transaction(function () use ($tenant, $connection, $types, $registry, $user): array {
|
|
$lockedConnection = ProviderConnection::query()
|
|
->whereKey($connection->getKey())
|
|
->lockForUpdate()
|
|
->firstOrFail();
|
|
|
|
$activeRun = OperationRun::query()
|
|
->where('tenant_id', $tenant->getKey())
|
|
->active()
|
|
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
|
|
->orderByDesc('id')
|
|
->first();
|
|
|
|
if ($activeRun instanceof OperationRun) {
|
|
return [
|
|
'status' => 'scope_busy',
|
|
'run' => $activeRun,
|
|
];
|
|
}
|
|
|
|
$runsService = app(OperationRunService::class);
|
|
|
|
$bootstrapRuns = [];
|
|
|
|
foreach ($types as $operationType) {
|
|
$definition = $registry->get($operationType);
|
|
|
|
$context = [
|
|
'wizard' => [
|
|
'flow' => 'managed_tenant_onboarding',
|
|
'step' => 'bootstrap',
|
|
],
|
|
'provider' => $lockedConnection->provider,
|
|
'module' => $definition['module'],
|
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
'target_scope' => [
|
|
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
|
|
],
|
|
];
|
|
|
|
$run = $runsService->ensureRunWithIdentity(
|
|
tenant: $tenant,
|
|
type: $operationType,
|
|
identityInputs: [
|
|
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
],
|
|
context: $context,
|
|
initiator: $user,
|
|
);
|
|
|
|
if ($run->wasRecentlyCreated) {
|
|
$this->dispatchBootstrapJob(
|
|
operationType: $operationType,
|
|
tenantId: (int) $tenant->getKey(),
|
|
userId: (int) $user->getKey(),
|
|
providerConnectionId: (int) $lockedConnection->getKey(),
|
|
run: $run,
|
|
);
|
|
}
|
|
|
|
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
|
}
|
|
|
|
return [
|
|
'status' => 'started',
|
|
'runs' => $bootstrapRuns,
|
|
];
|
|
});
|
|
|
|
if ($result['status'] === 'scope_busy') {
|
|
Notification::make()
|
|
->title('Another operation is already running')
|
|
->body('Please wait for the active run to finish.')
|
|
->warning()
|
|
->actions([
|
|
Action::make('view_run')
|
|
->label('View run')
|
|
->url(OperationRunLinks::view($result['run'], $tenant)),
|
|
])
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$bootstrapRuns = $result['runs'];
|
|
|
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$state = $this->onboardingSession->state ?? [];
|
|
|
|
$existing = $state['bootstrap_operation_runs'] ?? [];
|
|
$existing = is_array($existing) ? $existing : [];
|
|
|
|
$state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns);
|
|
$state['bootstrap_operation_types'] = $types;
|
|
|
|
$this->onboardingSession->state = $state;
|
|
$this->onboardingSession->current_step = 'bootstrap';
|
|
$this->onboardingSession->updated_by_user_id = (int) $user->getKey();
|
|
$this->onboardingSession->save();
|
|
}
|
|
|
|
Notification::make()
|
|
->title('Bootstrap started')
|
|
->success()
|
|
->send();
|
|
}
|
|
|
|
private function dispatchBootstrapJob(
|
|
string $operationType,
|
|
int $tenantId,
|
|
int $userId,
|
|
int $providerConnectionId,
|
|
OperationRun $run,
|
|
): void {
|
|
match ($operationType) {
|
|
'inventory.sync' => ProviderInventorySyncJob::dispatch(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
providerConnectionId: $providerConnectionId,
|
|
operationRun: $run,
|
|
),
|
|
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
|
|
tenantId: $tenantId,
|
|
userId: $userId,
|
|
providerConnectionId: $providerConnectionId,
|
|
operationRun: $run,
|
|
),
|
|
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
|
|
};
|
|
}
|
|
|
|
public function verificationSucceeded(): bool
|
|
{
|
|
return $this->verificationHasSucceeded();
|
|
}
|
|
|
|
public function completeOnboarding(): void
|
|
{
|
|
$user = auth()->user();
|
|
|
|
if (! $user instanceof User) {
|
|
abort(403);
|
|
}
|
|
|
|
$this->authorizeWorkspaceMutation($user);
|
|
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
abort(404);
|
|
}
|
|
|
|
if (! $this->verificationHasSucceeded()) {
|
|
Notification::make()
|
|
->title('Verification required')
|
|
->body('Complete verification successfully before finishing onboarding.')
|
|
->warning()
|
|
->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$tenant = $this->managedTenant->fresh();
|
|
|
|
if (! $tenant instanceof Tenant) {
|
|
abort(404);
|
|
}
|
|
|
|
DB::transaction(function () use ($tenant, $user): void {
|
|
$tenant->update(['status' => 'active']);
|
|
|
|
$this->onboardingSession->forceFill([
|
|
'completed_at' => now(),
|
|
'current_step' => 'complete',
|
|
'updated_by_user_id' => (int) $user->getKey(),
|
|
])->save();
|
|
});
|
|
|
|
$this->redirect(TenantDashboard::getUrl(tenant: $tenant));
|
|
}
|
|
|
|
private function verificationHasSucceeded(): bool
|
|
{
|
|
if (! $this->managedTenant instanceof Tenant) {
|
|
return false;
|
|
}
|
|
|
|
if (! $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
return false;
|
|
}
|
|
|
|
$runId = $this->onboardingSession->state['verification_operation_run_id'] ?? null;
|
|
|
|
if (! is_int($runId)) {
|
|
return false;
|
|
}
|
|
|
|
$run = OperationRun::query()
|
|
->where('tenant_id', (int) $this->managedTenant->getKey())
|
|
->whereKey($runId)
|
|
->first();
|
|
|
|
if (! $run instanceof OperationRun) {
|
|
return false;
|
|
}
|
|
|
|
return $run->status === 'completed' && $run->outcome === 'succeeded';
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function bootstrapOperationOptions(): array
|
|
{
|
|
$registry = app(ProviderOperationRegistry::class);
|
|
|
|
return collect($registry->all())
|
|
->reject(fn (array $definition, string $type): bool => $type === 'provider.connection.check')
|
|
->mapWithKeys(fn (array $definition, string $type): array => [$type => (string) ($definition['label'] ?? $type)])
|
|
->all();
|
|
}
|
|
|
|
private function resolveDefaultProviderConnectionId(Tenant $tenant): ?int
|
|
{
|
|
$id = ProviderConnection::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('is_default', true)
|
|
->orderByDesc('id')
|
|
->value('id');
|
|
|
|
if (is_int($id)) {
|
|
return $id;
|
|
}
|
|
|
|
$fallback = ProviderConnection::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->orderByDesc('id')
|
|
->value('id');
|
|
|
|
return is_int($fallback) ? $fallback : null;
|
|
}
|
|
|
|
private function resolveSelectedProviderConnection(Tenant $tenant): ?ProviderConnection
|
|
{
|
|
$providerConnectionId = $this->selectedProviderConnectionId;
|
|
|
|
if (! is_int($providerConnectionId) && $this->onboardingSession instanceof TenantOnboardingSession) {
|
|
$candidate = $this->onboardingSession->state['provider_connection_id'] ?? null;
|
|
$providerConnectionId = is_int($candidate) ? $candidate : null;
|
|
}
|
|
|
|
if (! is_int($providerConnectionId)) {
|
|
$providerConnectionId = $this->resolveDefaultProviderConnectionId($tenant);
|
|
}
|
|
|
|
if (! is_int($providerConnectionId)) {
|
|
return null;
|
|
}
|
|
|
|
return ProviderConnection::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->whereKey($providerConnectionId)
|
|
->first();
|
|
}
|
|
}
|