*/ public array $data = []; public ?string $sessionId = null; public ?int $tenantId = null; public ?string $currentStep = null; public ?int $verificationRunId = null; public function mount(): void { $this->authorizeAccess(); $user = auth()->user(); if (! $user instanceof User) { abort(403); } $tenant = $this->resolveTenantFromRequest(); $sessionService = app(TenantOnboardingSessionService::class); if (filled(request()->query('session'))) { $session = $sessionService->resumeById($user, (string) request()->query('session')); } else { $session = $sessionService->startOrResume($user, $tenant); } $this->sessionId = (string) $session->getKey(); $this->tenantId = $session->tenant_id; $this->currentStep = (string) $session->current_step; $this->data = array_merge($this->data, $session->payload ?? []); if ($tenant instanceof Tenant) { $this->data = array_merge($this->data, [ 'name' => $tenant->name, 'tenant_id' => $tenant->tenant_id, 'domain' => $tenant->domain, 'environment' => $tenant->environment, 'app_client_id' => $tenant->app_client_id, 'app_certificate_thumbprint' => $tenant->app_certificate_thumbprint, 'app_notes' => $tenant->app_notes, ]); } $this->form->fill($this->data); } public function enqueueVerification(): void { $this->authorizeAccess(); $this->requireCapability(Capabilities::PROVIDER_RUN); $user = auth()->user(); if (! $user instanceof User) { abort(403); } $tenant = $this->requireTenant(); /** @var OperationRunService $runs */ $runs = app(OperationRunService::class); $run = $runs->ensureRunWithIdentity( tenant: $tenant, type: 'tenant.rbac.verify', identityInputs: [ 'purpose' => 'tenant_rbac_verify', ], context: [ 'operation' => [ 'type' => 'tenant.rbac.verify', ], 'target_scope' => [ 'tenant_id' => $tenant->getKey(), 'entra_tenant_id' => $tenant->tenant_id, ], ], initiator: $user, ); $this->verificationRunId = (int) $run->getKey(); if ($run->wasRecentlyCreated) { $runs->dispatchOrFail($run, function (OperationRun $run) use ($tenant, $user): void { TenantOnboardingVerifyJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), operationRun: $run, ); }); Notification::make()->title('Verification queued')->success()->send(); return; } Notification::make()->title('Verification already in progress')->info()->send(); } public function enqueueConnectionCheck(): void { $this->authorizeAccess(); $this->requireCapability(Capabilities::PROVIDER_RUN); $user = auth()->user(); if (! $user instanceof User) { abort(403); } $tenant = $this->requireTenant(); $connection = $this->ensureDefaultMicrosoftProviderConnection($tenant); /** @var ProviderOperationStartGate $gate */ $gate = app(ProviderOperationStartGate::class); $result = $gate->start( tenant: $tenant, connection: $connection, operationType: 'provider.connection.check', dispatcher: function (OperationRun $operationRun) use ($tenant, $user, $connection): void { ProviderConnectionHealthCheckJob::dispatch( tenantId: (int) $tenant->getKey(), userId: (int) $user->getKey(), providerConnectionId: (int) $connection->getKey(), operationRun: $operationRun, ); }, initiator: $user, ); if ($result->status === 'scope_busy') { Notification::make()->title('Scope busy')->warning()->send(); return; } if ($result->status === 'deduped') { Notification::make()->title('Connection check already queued')->info()->send(); return; } Notification::make()->title('Connection check queued')->success()->send(); } public function form(Schema $schema): Schema { return $schema ->statePath('data') ->components([ Wizard::make($this->getSteps()) ->startOnStep(fn (): int => $this->getStartStep()) ->submitAction('') ->cancelAction(''), ]); } /** * @return array */ private function getSteps(): array { $steps = [ Step::make('Welcome') ->id('welcome') ->description('Requirements and what to expect.') ->schema([ \Filament\Forms\Components\Placeholder::make('welcome_copy') ->label('') ->content('This wizard will create or update a tenant record without making any outbound calls. You can resume at any time.'), ]) ->afterValidation(fn (): mixed => $this->persistStep('tenant_details')), Step::make('Tenant Details') ->id('tenant_details') ->description('Basic tenant metadata') ->schema([ TextInput::make('name') ->label('Display name') ->required() ->maxLength(255), Select::make('environment') ->options([ 'prod' => 'PROD', 'dev' => 'DEV', 'staging' => 'STAGING', 'other' => 'Other', ]) ->default('other') ->required(), TextInput::make('tenant_id') ->label('Tenant ID (GUID)') ->required() ->rule('uuid') ->maxLength(255), TextInput::make('domain') ->label('Primary domain') ->maxLength(255), ]) ->afterValidation(fn (): mixed => $this->handleTenantDetailsCompleted()), ]; $steps = array_merge($steps, $this->credentialsRequired() ? [$this->credentialsStep()] : []); $steps[] = Step::make('Admin Consent & Permissions') ->id('permissions') ->description('Grant permissions and verify access') ->schema([ \Filament\Forms\Components\Placeholder::make('permissions_copy') ->label('') ->content('Next, you will grant admin consent and verify permissions. (Verification runs are implemented in the next phase.)'), ]) ->afterValidation(fn (): mixed => $this->persistStep('verification')); $steps[] = Step::make('Verification / First Run') ->id('verification') ->description('Finish setup and validate readiness') ->schema([ \Filament\Forms\Components\Placeholder::make('verification_copy') ->label('') ->content('Verification checks are enqueue-only and will appear here once implemented.'), ]) ->afterValidation(fn (): mixed => $this->persistStep('verification')); return $steps; } private function credentialsStep(): Step { return Step::make('App / Credentials') ->id('credentials') ->description('Set credentials (if required)') ->schema([ TextInput::make('app_client_id') ->label('App Client ID') ->required() ->maxLength(255), TextInput::make('app_client_secret') ->label('App Client Secret') ->password() ->required() ->maxLength(255), TextInput::make('app_certificate_thumbprint') ->label('Certificate thumbprint') ->maxLength(255), Textarea::make('app_notes') ->label('Notes') ->rows(3), Checkbox::make('acknowledge_credentials') ->label('I understand this will store credentials encrypted and they cannot be shown again.') ->accepted() ->required(), ]) ->afterValidation(fn (): mixed => $this->handleCredentialsCompleted()); } private function handleTenantDetailsCompleted(): void { $this->authorizeAccess(); $this->requireCapability(Capabilities::TENANT_MANAGE); $tenant = $this->upsertTenantFromData(); $this->ensureDefaultMicrosoftProviderConnection($tenant); $this->tenantId = (int) $tenant->getKey(); $nextStep = $this->credentialsRequired() ? 'credentials' : 'permissions'; $this->persistStep($nextStep, $tenant); Notification::make()->title('Tenant details saved')->success()->send(); } private function handleCredentialsCompleted(): void { $this->authorizeAccess(); $this->requireCapability(Capabilities::TENANT_MANAGE); $tenant = $this->requireTenant(); $secret = (string) ($this->data['app_client_secret'] ?? ''); $tenant->forceFill([ 'app_client_id' => $this->data['app_client_id'] ?? null, 'app_certificate_thumbprint' => $this->data['app_certificate_thumbprint'] ?? null, 'app_notes' => $this->data['app_notes'] ?? null, ]); if (filled($secret)) { $tenant->forceFill(['app_client_secret' => $secret]); } $tenant->save(); if (filled($secret) && filled($tenant->app_client_id) && filled($tenant->tenant_id)) { $connection = $this->ensureDefaultMicrosoftProviderConnection($tenant); app(CredentialManager::class)->upsertClientSecretCredential( connection: $connection, clientId: (string) $tenant->app_client_id, clientSecret: (string) $secret, ); } if (filled($secret)) { $actor = auth()->user(); app(TenantOnboardingAuditService::class)->credentialsUpdated( tenant: $tenant, actor: $actor instanceof User ? $actor : null, context: [ 'app_client_id_set' => filled($tenant->app_client_id), 'app_client_secret_set' => true, ], ); } $this->data['app_client_secret'] = null; $this->form->fill($this->data); $this->persistStep('permissions', $tenant); Notification::make()->title('Credentials saved')->success()->send(); } private function persistStep(string $currentStep, ?Tenant $tenant = null): void { $this->authorizeAccess(); $this->requireCapability(Capabilities::TENANT_MANAGE); $user = auth()->user(); if (! $user instanceof User) { abort(403); } $session = $this->requireSession(); $service = app(TenantOnboardingSessionService::class); $updated = $service->persistProgress( session: $session, currentStep: $currentStep, payload: $this->data, tenant: $tenant, ); $this->sessionId = (string) $updated->getKey(); $this->tenantId = $updated->tenant_id; $this->currentStep = (string) $updated->current_step; } /** * DB-only: uses config + stored tenant_permissions. * * @return array,status:string}> */ public function permissionRows(): array { if (! $this->tenantId) { return []; } $tenant = Tenant::query()->whereKey($this->tenantId)->first(); if (! $tenant instanceof Tenant) { return []; } $required = config('intune_permissions.permissions', []); $required = is_array($required) ? $required : []; $granted = TenantPermission::query() ->where('tenant_id', $tenant->getKey()) ->get() ->keyBy('permission_key'); $rows = []; foreach ($required as $permission) { if (! is_array($permission)) { continue; } $key = (string) ($permission['key'] ?? ''); if ($key === '') { continue; } $stored = $granted->get($key); $status = $stored instanceof TenantPermission ? (string) $stored->status : 'missing'; $rows[] = [ 'key' => $key, 'type' => (string) ($permission['type'] ?? 'application'), 'description' => isset($permission['description']) && is_string($permission['description']) ? $permission['description'] : null, 'features' => is_array($permission['features'] ?? null) ? $permission['features'] : [], 'status' => $status, ]; } return $rows; } public function latestVerificationRunStatus(): ?string { if (! $this->tenantId) { return null; } $run = OperationRun::query() ->where('tenant_id', $this->tenantId) ->where('type', 'tenant.rbac.verify') ->orderByDesc('id') ->first(); if (! $run instanceof OperationRun) { return null; } return (string) $run->status; } public function latestConnectionCheckRunStatus(): ?string { if (! $this->tenantId) { return null; } $tenant = Tenant::query()->whereKey($this->tenantId)->first(); if (! $tenant instanceof Tenant) { return null; } $connection = $tenant->providerConnections() ->where('provider', 'microsoft') ->where('entra_tenant_id', $tenant->tenant_id) ->orderByDesc('is_default') ->orderByDesc('id') ->first(); if (! $connection instanceof ProviderConnection) { return null; } $run = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'provider.connection.check') ->where('context->provider_connection_id', (int) $connection->getKey()) ->orderByDesc('id') ->first(); if (! $run instanceof OperationRun) { return null; } return (string) $run->status; } public function isReadyToCompleteOnboarding(): bool { if (! $this->tenantId) { return false; } $tenant = Tenant::query()->whereKey($this->tenantId)->first(); if (! $tenant instanceof Tenant) { return false; } $connection = $tenant->providerConnections() ->where('provider', 'microsoft') ->where('entra_tenant_id', $tenant->tenant_id) ->orderByDesc('is_default') ->orderByDesc('id') ->first(); $connectionOk = $connection instanceof ProviderConnection && (string) $connection->health_status === 'ok' && (string) $connection->status === 'connected'; $permissionsOk = collect($this->permissionRows()) ->every(fn (array $row): bool => (string) ($row['status'] ?? 'missing') === 'granted'); $verifyRunOk = OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'tenant.rbac.verify') ->where('status', OperationRunStatus::Completed->value) ->where('outcome', 'succeeded') ->exists(); return $connectionOk && $permissionsOk && $verifyRunOk; } private function ensureDefaultMicrosoftProviderConnection(Tenant $tenant): ProviderConnection { $existing = $tenant->providerConnections() ->where('provider', 'microsoft') ->where('entra_tenant_id', $tenant->tenant_id) ->orderByDesc('is_default') ->orderByDesc('id') ->first(); if ($existing instanceof ProviderConnection) { if (! $existing->is_default) { $existing->makeDefault(); } return $existing; } return ProviderConnection::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Microsoft Graph', 'is_default' => true, 'status' => 'needs_consent', 'health_status' => 'unknown', 'scopes_granted' => [], 'metadata' => [], ]); } private function upsertTenantFromData(): Tenant { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $tenantGuid = Str::lower((string) ($this->data['tenant_id'] ?? '')); $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->first(); $isNewTenant = ! $tenant instanceof Tenant; if (! $tenant instanceof Tenant) { $tenant = new Tenant(); $tenant->forceFill([ 'status' => 'active', ]); } $tenant->forceFill([ 'name' => $this->data['name'] ?? null, 'tenant_id' => $tenantGuid, 'domain' => $this->data['domain'] ?? null, 'environment' => $this->data['environment'] ?? 'other', 'onboarding_status' => 'in_progress', 'onboarding_completed_at' => null, ]); try { $tenant->save(); } catch (QueryException $exception) { throw $exception; } $alreadyMember = $user->tenants()->whereKey($tenant->getKey())->exists(); if (! $alreadyMember) { $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => [ 'role' => 'owner', 'source' => 'manual', 'created_by_user_id' => $user->getKey(), ], ]); } if ($isNewTenant && ! $alreadyMember) { app(AuditLogger::class)->log( tenant: $tenant, action: 'tenant_membership.bootstrap_assign', context: [ 'metadata' => [ 'user_id' => (int) $user->getKey(), 'role' => 'owner', 'source' => 'manual', ], ], actorId: (int) $user->getKey(), actorEmail: $user->email, actorName: $user->name, status: 'success', resourceType: 'tenant', resourceId: (string) $tenant->getKey(), ); } return $tenant; } private function authorizeAccess(): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $tenant = $this->resolveTenantFromRequest(); /** @var CapabilityResolver $resolver */ $resolver = app(CapabilityResolver::class); if ($tenant instanceof Tenant) { if (! $resolver->isMember($user, $tenant)) { abort(404); } if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) { abort(403); } return; } // For creating a new tenant (not yet in scope), require that the user can manage at least one tenant. $canManageAny = $user->tenantMemberships() ->pluck('role') ->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE)); if (! $canManageAny) { abort(403); } } private function resolveTenantFromRequest(): ?Tenant { $tenantExternalId = request()->query('tenant'); if (! is_string($tenantExternalId) || blank($tenantExternalId)) { return null; } return Tenant::query() ->where('external_id', $tenantExternalId) ->where('status', 'active') ->first(); } private function credentialsRequired(): bool { return (bool) config('tenantpilot.onboarding.credentials_required', false); } private function getStartStep(): int { $session = $this->requireSession(); $keys = $this->getStepKeys(); $index = array_search((string) $session->current_step, $keys, true); if ($index === false) { return 1; } return $index + 1; } /** * @return array */ private function getStepKeys(): array { $keys = ['welcome', 'tenant_details']; if ($this->credentialsRequired()) { $keys[] = 'credentials'; } $keys[] = 'permissions'; $keys[] = 'verification'; return $keys; } private function requireSession(): TenantOnboardingSession { if (! filled($this->sessionId)) { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $this->sessionId = (string) app(TenantOnboardingSessionService::class)->startOrResume($user)->getKey(); } return TenantOnboardingSession::query()->whereKey($this->sessionId)->firstOrFail(); } private function requireTenant(): Tenant { if (! $this->tenantId) { abort(400, 'Tenant not initialized'); } return Tenant::query()->whereKey($this->tenantId)->firstOrFail(); } public function tenantHasClientSecret(): bool { if (! $this->tenantId) { return false; } $tenant = Tenant::query()->whereKey($this->tenantId)->first(); return $tenant instanceof Tenant && filled($tenant->getRawOriginal('app_client_secret')); } public function canRunProviderOperations(): bool { $user = auth()->user(); if (! $user instanceof User) { return false; } $tenant = $this->resolveTenantForAuthorization(); return $tenant instanceof Tenant && app(CapabilityResolver::class)->can($user, $tenant, Capabilities::PROVIDER_RUN); } private function requireCapability(string $capability): void { $user = auth()->user(); if (! $user instanceof User) { abort(403); } $tenant = $this->resolveTenantForAuthorization(); if (! $tenant instanceof Tenant || ! app(CapabilityResolver::class)->can($user, $tenant, $capability)) { abort(403); } } private function resolveTenantForAuthorization(): ?Tenant { if ($this->tenantId) { $tenant = Tenant::query()->whereKey($this->tenantId)->first(); if ($tenant instanceof Tenant) { return $tenant; } } return $this->resolveTenantFromRequest(); } }