diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index 176f98f..b481a09 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -14,6 +14,8 @@ ## Active Technologies - PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish) - PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting) - PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting) +- PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 (069-managed-tenant-onboarding-wizard) +- PostgreSQL (Sail) (069-managed-tenant-onboarding-wizard) - PHP 8.4.15 (feat/005-bulk-operations) @@ -33,9 +35,9 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 069-managed-tenant-onboarding-wizard: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4 - 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 - 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 -- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A] diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index 5940f23..90562c1 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -21,30 +21,14 @@ public static function getLabel(): string public static function canView(): bool { - $user = auth()->user(); - - if (! $user instanceof User) { - return false; - } - - $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); - - if ($tenantIds->isEmpty()) { - return false; - } - - /** @var CapabilityResolver $resolver */ - $resolver = app(CapabilityResolver::class); - - foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { - if ($resolver->can($user, $tenant, Capabilities::TENANT_MANAGE)) { - return true; - } - } - return false; } + public function mount(): void + { + abort(404); + } + public function form(Schema $schema): Schema { return $schema diff --git a/app/Filament/Pages/TenantOnboardingWizard.php b/app/Filament/Pages/TenantOnboardingWizard.php new file mode 100644 index 0000000..3940596 --- /dev/null +++ b/app/Filament/Pages/TenantOnboardingWizard.php @@ -0,0 +1,820 @@ + + */ + 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(); + } +} diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 6d3bd89..e191007 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\TenantResource\Pages; +use App\Filament\Pages\TenantOnboardingWizard; use App\Filament\Resources\TenantResource; use App\Models\User; use Filament\Resources\Pages\CreateRecord; @@ -10,6 +11,11 @@ class CreateTenant extends CreateRecord { protected static string $resource = TenantResource::class; + public function mount(): void + { + $this->redirect(TenantOnboardingWizard::getUrl()); + } + protected function afterCreate(): void { $user = auth()->user(); diff --git a/app/Filament/Resources/TenantResource/Pages/ListTenants.php b/app/Filament/Resources/TenantResource/Pages/ListTenants.php index 48fcad2..23836f8 100644 --- a/app/Filament/Resources/TenantResource/Pages/ListTenants.php +++ b/app/Filament/Resources/TenantResource/Pages/ListTenants.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\TenantResource\Pages; +use App\Filament\Pages\TenantOnboardingWizard; use App\Filament\Resources\TenantResource; use Filament\Actions; use Filament\Resources\Pages\ListRecords; @@ -13,9 +14,12 @@ class ListTenants extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make() + Actions\Action::make('onboardTenant') + ->label('Onboard tenant') + ->icon('heroicon-o-sparkles') + ->url(fn (): string => TenantOnboardingWizard::getUrl()) ->disabled(fn (): bool => ! TenantResource::canCreate()) - ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), + ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to onboard tenants.'), ]; } } diff --git a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php index f92b500..b05acc4 100644 --- a/app/Filament/Resources/TenantResource/Pages/ViewTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/ViewTenant.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource; +use App\Filament\Pages\TenantOnboardingWizard; use App\Filament\Widgets\Tenant\TenantArchivedBanner; use App\Models\Tenant; use App\Services\Intune\AuditLogger; @@ -38,6 +39,17 @@ protected function getHeaderActions(): array ) ->requireCapability(Capabilities::TENANT_MANAGE) ->apply(), + + UiEnforcement::forAction( + Actions\Action::make('resume_onboarding') + ->label('Resume onboarding') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->url(fn (Tenant $record): string => TenantOnboardingWizard::getUrl().'?tenant='.$record->external_id) + ->visible(fn (Tenant $record): bool => (string) ($record->onboarding_status ?? 'not_started') !== 'completed') + ) + ->requireCapability(Capabilities::TENANT_MANAGE) + ->apply(), Actions\Action::make('admin_consent') ->label('Admin consent') ->icon('heroicon-o-clipboard-document') diff --git a/app/Jobs/TenantOnboardingVerifyJob.php b/app/Jobs/TenantOnboardingVerifyJob.php new file mode 100644 index 0000000..93628ee --- /dev/null +++ b/app/Jobs/TenantOnboardingVerifyJob.php @@ -0,0 +1,122 @@ +operationRun = $operationRun; + } + + /** + * @return array + */ + public function middleware(): array + { + return [new TrackOperationRun]; + } + + public function handle( + TenantPermissionService $permissions, + OperationRunService $runs, + TenantOnboardingAuditService $audit, + ): void { + $tenant = Tenant::query()->find($this->tenantId); + if (! $tenant instanceof Tenant) { + throw new RuntimeException('Tenant not found.'); + } + + $user = User::query()->find($this->userId); + if (! $user instanceof User) { + throw new RuntimeException('User not found.'); + } + + $result = $permissions->compare( + tenant: $tenant, + grantedStatuses: null, + persist: true, + liveCheck: true, + useConfiguredStub: false, + ); + + $overall = (string) ($result['overall_status'] ?? 'error'); + + $tenant->forceFill([ + 'rbac_last_checked_at' => now(), + 'rbac_last_warnings' => $overall === 'granted' ? [] : ['permissions_not_granted'], + ])->save(); + + if (! $this->operationRun instanceof OperationRun) { + return; + } + + if ($overall === 'granted') { + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Succeeded->value, + ); + + $tenant->forceFill([ + 'onboarding_status' => 'completed', + 'onboarding_completed_at' => now(), + ])->save(); + + TenantOnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->where('status', 'active') + ->update([ + 'status' => 'completed', + 'current_step' => 'verification', + 'completed_at' => now(), + ]); + + $audit->onboardingCompleted( + tenant: $tenant, + actor: $user, + context: [ + 'operation_run_id' => (int) $this->operationRun->getKey(), + ], + ); + + return; + } + + $runs->updateRun( + $this->operationRun, + status: OperationRunStatus::Completed->value, + outcome: OperationRunOutcome::Failed->value, + failures: [[ + 'code' => 'tenant.rbac.verify.not_granted', + 'message' => 'Permissions are missing or could not be verified.', + ]], + ); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index c1d98b6..a6585ec 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -26,6 +26,7 @@ class Tenant extends Model implements HasName 'metadata' => 'array', 'app_client_secret' => 'encrypted', 'is_current' => 'boolean', + 'onboarding_completed_at' => 'datetime', 'rbac_last_checked_at' => 'datetime', 'rbac_last_setup_at' => 'datetime', 'rbac_canary_results' => 'array', @@ -170,6 +171,11 @@ public function memberships(): HasMany return $this->hasMany(TenantMembership::class); } + public function onboardingSessions(): HasMany + { + return $this->hasMany(TenantOnboardingSession::class); + } + public function roleMappings(): HasMany { return $this->hasMany(TenantRoleMapping::class); diff --git a/app/Models/TenantOnboardingSession.php b/app/Models/TenantOnboardingSession.php new file mode 100644 index 0000000..7345b1b --- /dev/null +++ b/app/Models/TenantOnboardingSession.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + use HasUuids; + + public $incrementing = false; + + protected $keyType = 'string'; + + protected $guarded = []; + + protected $casts = [ + 'payload' => 'array', + 'completed_at' => 'datetime', + 'abandoned_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function createdByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index f4fae79..b25350f 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -5,6 +5,7 @@ use App\Filament\Pages\Auth\Login; use App\Filament\Pages\ChooseTenant; use App\Filament\Pages\NoAccess; +use App\Filament\Pages\TenantOnboardingWizard; use App\Filament\Pages\Tenancy\RegisterTenant; use App\Filament\Pages\TenantDashboard; use App\Models\Tenant; @@ -39,6 +40,7 @@ public function panel(Panel $panel): Panel ->authenticatedRoutes(function (Panel $panel): void { ChooseTenant::registerRoutes($panel); NoAccess::registerRoutes($panel); + TenantOnboardingWizard::registerRoutes($panel); }) ->tenant(Tenant::class, slugAttribute: 'external_id') ->tenantRoutePrefix('t') diff --git a/app/Services/TenantOnboardingAuditService.php b/app/Services/TenantOnboardingAuditService.php new file mode 100644 index 0000000..521e7e2 --- /dev/null +++ b/app/Services/TenantOnboardingAuditService.php @@ -0,0 +1,68 @@ +sanitizeContext($context); + + return $this->auditLogger->log( + tenant: $tenant, + action: AuditActions::TENANT_ONBOARDING_CREDENTIALS_UPDATED, + context: $context, + actorId: $actor?->id, + actorEmail: $actor?->email, + actorName: $actor?->name, + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); + } + + public function onboardingCompleted(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog + { + $context = $this->sanitizeContext($context); + + return $this->auditLogger->log( + tenant: $tenant, + action: AuditActions::TENANT_ONBOARDING_COMPLETED, + context: $context, + actorId: $actor?->id, + actorEmail: $actor?->email, + actorName: $actor?->name, + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); + } + + /** + * @param array $context + * @return array + */ + private function sanitizeContext(array $context): array + { + $keysToStrip = [ + 'secret', + 'client_secret', + 'app_client_secret', + 'app_secret', + 'token', + 'access_token', + 'refresh_token', + ]; + + return Arr::except($context, $keysToStrip); + } +} diff --git a/app/Services/TenantOnboardingSessionService.php b/app/Services/TenantOnboardingSessionService.php new file mode 100644 index 0000000..42d7af8 --- /dev/null +++ b/app/Services/TenantOnboardingSessionService.php @@ -0,0 +1,142 @@ +where('tenant_id', $tenant->getKey()) + ->where('status', 'active') + ->first(); + + if ($existing instanceof TenantOnboardingSession) { + return $existing; + } + } + + return TenantOnboardingSession::query()->create([ + 'tenant_id' => $tenant?->getKey(), + 'created_by_user_id' => $user->getKey(), + 'status' => 'active', + 'current_step' => 'welcome', + 'payload' => [], + ]); + } + + public function resumeById(User $user, string $sessionId): TenantOnboardingSession + { + $session = TenantOnboardingSession::query()->whereKey($sessionId)->firstOrFail(); + + if ((int) $session->created_by_user_id !== (int) $user->getKey()) { + abort(404); + } + + return $session; + } + + /** + * Persist wizard progress + non-secret payload. + * + * @param array $payload + */ + public function persistProgress(TenantOnboardingSession $session, string $currentStep, array $payload, ?Tenant $tenant = null): TenantOnboardingSession + { + $payload = $this->sanitizePayload($payload); + + return DB::transaction(function () use ($session, $currentStep, $payload, $tenant): TenantOnboardingSession { + $session->forceFill([ + 'current_step' => $currentStep, + 'payload' => array_merge($session->payload ?? [], $payload), + ]); + + if ($tenant instanceof Tenant) { + $session->tenant()->associate($tenant); + } + + try { + $session->save(); + } catch (QueryException $exception) { + // If another active session already exists for the tenant, resume it. + if (($tenant instanceof Tenant) && $this->isActiveSessionUniqueViolation($exception)) { + $existing = TenantOnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->where('status', 'active') + ->first(); + + if ($existing instanceof TenantOnboardingSession) { + return $existing; + } + } + + throw $exception; + } + + return $session; + }); + } + + /** + * @param array $payload + * @return array + */ + public function sanitizePayload(array $payload): array + { + $forbiddenKeys = [ + 'app_client_secret', + 'client_secret', + 'secret', + 'token', + 'access_token', + 'refresh_token', + 'password', + ]; + + return $this->forgetKeysRecursive($payload, $forbiddenKeys); + } + + /** + * @param array $payload + * @param array $forbiddenKeys + * @return array + */ + private function forgetKeysRecursive(array $payload, array $forbiddenKeys): array + { + foreach ($forbiddenKeys as $key) { + Arr::forget($payload, $key); + } + + foreach ($payload as $key => $value) { + if (! is_array($value)) { + continue; + } + + $payload[$key] = $this->forgetKeysRecursive($value, $forbiddenKeys); + } + + return $payload; + } + + private function isActiveSessionUniqueViolation(QueryException $exception): bool + { + $message = Str::lower($exception->getMessage()); + + return str_contains($message, 'tenant_onboarding_sessions_active_unique') + || str_contains($message, 'unique') && str_contains($message, 'tenant_onboarding_sessions'); + } +} diff --git a/app/Support/Audit/AuditActions.php b/app/Support/Audit/AuditActions.php new file mode 100644 index 0000000..a801029 --- /dev/null +++ b/app/Support/Audit/AuditActions.php @@ -0,0 +1,9 @@ + 'Restore restore runs', 'restore_run.force_delete' => 'Force delete restore runs', 'tenant.sync' => 'Tenant sync', + 'tenant.rbac.verify' => 'Tenant RBAC verification', 'policy_version.prune' => 'Prune policy versions', 'policy_version.restore' => 'Restore policy versions', 'policy_version.force_delete' => 'Delete policy versions', @@ -57,6 +58,7 @@ public static function expectedDurationSeconds(string $operationType): ?int return match (trim($operationType)) { 'policy.sync', 'policy.sync_one' => 90, 'provider.connection.check' => 30, + 'tenant.rbac.verify' => 60, 'policy.export' => 120, 'inventory.sync' => 180, 'compliance.snapshot' => 180, diff --git a/config/tenantpilot.php b/config/tenantpilot.php index 5467c5e..22dec39 100644 --- a/config/tenantpilot.php +++ b/config/tenantpilot.php @@ -6,6 +6,10 @@ 'ttl_minutes' => (int) env('BREAK_GLASS_TTL_MINUTES', 15), ], + 'onboarding' => [ + 'credentials_required' => (bool) env('TENANT_ONBOARDING_CREDENTIALS_REQUIRED', false), + ], + 'supported_policy_types' => [ [ 'type' => 'deviceConfiguration', diff --git a/database/factories/TenantOnboardingSessionFactory.php b/database/factories/TenantOnboardingSessionFactory.php new file mode 100644 index 0000000..5e4cf4b --- /dev/null +++ b/database/factories/TenantOnboardingSessionFactory.php @@ -0,0 +1,36 @@ + + */ +class TenantOnboardingSessionFactory extends Factory +{ + protected $model = TenantOnboardingSession::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'created_by_user_id' => User::factory(), + 'status' => 'active', + 'current_step' => 'welcome', + 'payload' => [], + 'last_error_code' => null, + 'last_error_message' => null, + 'completed_at' => null, + 'abandoned_at' => null, + ]; + } +} diff --git a/database/migrations/2026_01_31_235108_create_tenant_onboarding_sessions_table.php b/database/migrations/2026_01_31_235108_create_tenant_onboarding_sessions_table.php new file mode 100644 index 0000000..702ec99 --- /dev/null +++ b/database/migrations/2026_01_31_235108_create_tenant_onboarding_sessions_table.php @@ -0,0 +1,44 @@ +uuid('id')->primary(); + $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('created_by_user_id')->constrained('users')->cascadeOnDelete(); + $table->enum('status', ['active', 'completed', 'abandoned'])->default('active'); + $table->enum('current_step', ['welcome', 'tenant_details', 'credentials', 'permissions', 'verification'])->default('welcome'); + $table->jsonb('payload')->nullable(); + $table->string('last_error_code')->nullable(); + $table->text('last_error_message')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('abandoned_at')->nullable(); + $table->timestamps(); + + $table->index(['created_by_user_id', 'status']); + $table->index(['tenant_id', 'status']); + }); + + DB::statement("CREATE UNIQUE INDEX tenant_onboarding_sessions_active_unique ON tenant_onboarding_sessions (tenant_id) WHERE status = 'active' AND tenant_id IS NOT NULL"); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + DB::statement('DROP INDEX IF EXISTS tenant_onboarding_sessions_active_unique'); + + Schema::dropIfExists('tenant_onboarding_sessions'); + } +}; diff --git a/database/migrations/2026_01_31_235109_add_onboarding_status_to_tenants_table.php b/database/migrations/2026_01_31_235109_add_onboarding_status_to_tenants_table.php new file mode 100644 index 0000000..8846251 --- /dev/null +++ b/database/migrations/2026_01_31_235109_add_onboarding_status_to_tenants_table.php @@ -0,0 +1,32 @@ +string('onboarding_status')->default('not_started')->after('status'); + $table->timestamp('onboarding_completed_at')->nullable()->after('onboarding_status'); + $table->index('onboarding_status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropIndex(['onboarding_status']); + $table->dropColumn('onboarding_completed_at'); + $table->dropColumn('onboarding_status'); + }); + } +}; diff --git a/resources/views/filament/pages/tenant-onboarding-wizard.blade.php b/resources/views/filament/pages/tenant-onboarding-wizard.blade.php new file mode 100644 index 0000000..830c743 --- /dev/null +++ b/resources/views/filament/pages/tenant-onboarding-wizard.blade.php @@ -0,0 +1,126 @@ + +
+ @php($canRunProviderOps = $this->canRunProviderOperations()) + +
+
+
Tenant onboarding wizard
+
+ This is the single supported entry point for creating and onboarding tenants. + You can safely close this page and resume later. +
+
+ Note: the legacy “create tenant” screens are intentionally disabled to keep onboarding consistent and auditable. +
+
+
+ + @if ($this->tenantId) +
+
+
+ Session: {{ $this->sessionId }} +
+
+ Client secret: + {{ $this->tenantHasClientSecret() ? 'set' : 'missing' }} +
+
+ If you need to resume later, open this wizard again from the tenant’s “Resume onboarding” action. +
+
+
+ @endif + + @if ($this->tenantId && $this->currentStep === 'permissions') +
+
+
+
Required permissions
+ + Verify permissions + +
+ + @php($runStatus = $this->latestVerificationRunStatus()) + @if ($runStatus) +
+ Last verification run status: {{ $runStatus }} +
+ @endif + +
+ @forelse ($this->permissionRows() as $permission) +
+
+
{{ $permission['key'] }}
+ @if (! empty($permission['description'])) +
{{ $permission['description'] }}
+ @endif +
+ + @php($status = (string) ($permission['status'] ?? 'missing')) + $status === 'granted', + 'bg-rose-50 text-rose-700 dark:bg-rose-950 dark:text-rose-300' => in_array($status, ['missing', 'error'], true), + 'bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-300' => ! in_array($status, ['granted', 'missing', 'error'], true), + ])> + {{ ucfirst($status) }} + +
+ @empty +
+ No required permissions are configured. +
+ @endforelse +
+
+
+ @endif + + @if ($this->tenantId && $this->currentStep === 'verification') +
+
+
+
Verification
+ + Check connection + +
+ + @php($connectionRunStatus = $this->latestConnectionCheckRunStatus()) + @if ($connectionRunStatus) +
+ Last connection check status: {{ $connectionRunStatus }} +
+ @endif + +
+ @if ($this->isReadyToCompleteOnboarding()) + Ready: all stored checks look good. + @else + Not ready yet: run checks and ensure permissions are granted. + @endif +
+
+
+ @endif + +
+ {{ $this->form }} +
+
+
diff --git a/routes/web.php b/routes/web.php index b1ef093..8871e8a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -28,3 +28,5 @@ Route::get('/auth/entra/callback', [EntraController::class, 'callback']) ->middleware('throttle:entra-callback') ->name('auth.entra.callback'); + +Route::redirect('/admin/new', '/admin/choose-tenant'); diff --git a/specs/069-managed-tenant-onboarding-wizard/checklists/requirements.md b/specs/069-managed-tenant-onboarding-wizard/checklists/requirements.md new file mode 100644 index 0000000..d706058 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Managed Tenant Onboarding Wizard v1 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-31 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation (iteration 1): PASS +- Dependencies: Depends on Spec 068 v2 foundations (workspace + managed tenants + canonical terms/routes) as input for this feature. diff --git a/specs/069-managed-tenant-onboarding-wizard/contracts/onboarding-wizard.md b/specs/069-managed-tenant-onboarding-wizard/contracts/onboarding-wizard.md new file mode 100644 index 0000000..e616c36 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/contracts/onboarding-wizard.md @@ -0,0 +1,67 @@ +# Contracts — Managed Tenant Onboarding Wizard v1 + +This feature is primarily a Filament wizard UI, so the “contracts” are internal (Livewire actions + routes), plus the `OperationRun` types used for enqueue-only verification. + +## Routes (tenant-plane) + +All routes are within the `/admin` panel and tenant-prefixed (current panel config uses `tenantRoutePrefix('t')`). + +- `GET /admin/t/{tenant:external_id}/onboarding` + - Render wizard and resume active session (no outbound calls; DB-only) +- `POST /admin/t/{tenant:external_id}/onboarding/save` + - Persist current step + payload (DB-only) +- `POST /admin/t/{tenant:external_id}/onboarding/verify` + - Enqueue verification operation(s) as `OperationRun` records; return immediately + +## Livewire/Filament component actions + +All server-side actions MUST: +- enforce membership (404 for non-members) +- enforce capability (403 for members lacking the capability) +- never make outbound HTTP during render/mount + +### `startOrResume()` +- Input: tenant context (`tenant_id` inferred from route) +- Output: session id + current step +- Behavior: + - if an `active` session exists for tenant, load and continue + - else create new `active` session + +### `saveStep(string $step, array $state)` +- Input: + - `step`: one of `welcome|tenant_details|credentials|permissions|verification` + - `state`: non-secret state only +- Output: updated session +- Invariants: + - state must be validated per-step + - secrets MUST NOT be persisted + +### `enqueueVerification()` +- Input: none (reads from session + tenant) +- Output: list of `OperationRun` ids (or one id) +- Behavior: + - Use `OperationRunService` to create/dedupe an active run identity for this tenant + check type. + - Dispatch jobs using existing provider operation patterns where applicable. + +## OperationRun types + +Exact type names should follow existing `ProviderOperationRegistry` conventions. + +### `tenant.rbac.verify` (proposed) +- Scope: tenant +- Purpose: enqueue-only verification of tenant RBAC prerequisites +- Identity: stable for `(tenant_id, "tenant.rbac.verify")` when active +- Job side effects: + - update `tenants.rbac_last_checked_at` + - write sanitized warning messages to `tenants.rbac_last_warnings` + - store per-check result details in `tenants.rbac_canary_results` + +### Optional: `tenant.credentials.verify` (proposed) +- Scope: tenant +- Only if credentials step is enabled/required + +## Legacy entry points + +- `GET /admin/new` → redirect to “Choose workspace” (as per spec clarifications) +- Any other legacy onboarding entry points should be redirected or removed. + diff --git a/specs/069-managed-tenant-onboarding-wizard/data-model.md b/specs/069-managed-tenant-onboarding-wizard/data-model.md new file mode 100644 index 0000000..f17a031 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/data-model.md @@ -0,0 +1,87 @@ +# Data Model — Managed Tenant Onboarding Wizard v1 + +This design is aligned to current repo reality where the “managed tenant” is the existing `Tenant` model. + +## Entity: Tenant (`App\\Models\\Tenant`) + +### Relevant existing fields +- `id` (PK) +- `name` (display name) +- `tenant_id` (Entra tenant GUID; used as canonical external id) +- `external_id` (route key; kept in sync with `tenant_id` when present) +- `domain` (optional) +- `environment` (`prod|dev|staging|other`) +- `app_client_id` (optional) +- `app_client_secret` (encrypted cast; must never be displayed back to the user) +- RBAC health / verification storage: + - `rbac_last_checked_at` (datetime) + - `rbac_last_setup_at` (datetime) + - `rbac_canary_results` (array) + - `rbac_last_warnings` (array) + +### New fields (proposed) +If onboarding needs to be explicitly tracked on the tenant record: +- `onboarding_status` enum-like string: `not_started|in_progress|completed` (default: `not_started`) +- `onboarding_completed_at` nullable datetime + +Rationale: makes it cheap to render “Resume wizard” / completion status without loading session records. + +## Entity: TenantOnboardingSession (new) + +### Table name (proposed) +- `tenant_onboarding_sessions` + +### Columns +- `id` (PK) +- `tenant_id` nullable FK → `tenants.id` + - nullable at the very beginning if the user hasn’t provided a valid tenant GUID yet +- `created_by_user_id` FK → `users.id` +- `status` string: `active|completed|abandoned` +- `current_step` string: `welcome|tenant_details|credentials|permissions|verification` +- `payload` jsonb + - contains non-secret form state only (e.g., name, tenant_id, domain, environment) + - MUST NOT contain secrets +- `last_error_code` nullable string +- `last_error_message` nullable string (sanitized; no tokens/secrets) +- `completed_at` nullable datetime +- `abandoned_at` nullable datetime +- `created_at`, `updated_at` + +### Indexes and constraints +- Ensure at most one active session per tenant: + - PostgreSQL partial unique index: `(tenant_id)` where `status = 'active'` +- Dedupe/resume lookup: + - index `(created_by_user_id, status)` + - index `(tenant_id, status)` + +### State transitions +- `active` → `completed` when: + - tenant record exists + - credentials requirement (if enabled) is satisfied + - last verification run indicates success +- `active` → `abandoned` when user explicitly cancels + +## Entity: OperationRun (`App\\Models\\OperationRun`) + +Wizard-triggered checks must be observable via `OperationRun`. + +### Relevant fields +- `tenant_id` FK +- `type` string (examples already in repo: `provider.connection.check`, `inventory.sync`, `compliance.snapshot`) +- `status` / `outcome` +- `run_identity_hash` (dedupe identity) +- `context` (json) + +### Idempotency +Use `OperationRunService::ensureRun()` / `ensureRunWithIdentity()` to get DB-level active-run dedupe. + +## Capability / Authorization model +- Capabilities are strings from the canonical registry `App\\Support\\Auth\\Capabilities`. +- Capability checks: + - Membership: `CapabilityResolver::isMember()` + - Capability: `CapabilityResolver::can()` +- Tenant-scoped non-member access is denied-as-not-found (404) by `DenyNonMemberTenantAccess` middleware. +- Filament actions use `App\\Support\\Rbac\\UiEnforcement` to apply: + - hidden UI for non-members + - disabled UI + tooltip for members lacking the capability + - server-side 404/403 guardrails diff --git a/specs/069-managed-tenant-onboarding-wizard/plan.md b/specs/069-managed-tenant-onboarding-wizard/plan.md new file mode 100644 index 0000000..b93d9b5 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/plan.md @@ -0,0 +1,127 @@ +# Implementation Plan: Managed Tenant Onboarding Wizard v1 + +**Branch**: `069-managed-tenant-onboarding-wizard` | **Date**: 2026-01-31 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from [specs/069-managed-tenant-onboarding-wizard/spec.md](spec.md) + +## Summary + +Implement a tenant-plane onboarding wizard under `/admin` that: +- renders DB-only (no outbound HTTP during render/mount) +- persists resumable onboarding sessions (non-secret payload) +- triggers verification via enqueue-only `OperationRun` records +- enforces RBAC-UX semantics (non-member 404; member missing capability 403 + disabled UI) +- redirects/removes legacy onboarding entry points (notably `/admin/new`) + +## Technical Context + +**Language/Version**: PHP 8.4.x +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4 +**Storage**: PostgreSQL (Sail) +**Testing**: Pest v4 +**Target Platform**: Web app (tenant-plane `/admin`, platform-plane `/system`) +**Project Type**: Laravel monolith +**Performance Goals**: +- Wizard step render: DB-only +- Operation starts: authorize + create/reuse `OperationRun` + enqueue only +**Constraints**: +- No outbound HTTP during render/mount (DB-only render). +- Verification/health checks must be enqueue-only and observable via `OperationRun`. +- Capability checks must use the canonical registry `App\\Support\\Auth\\Capabilities` (no raw strings). +- Credential secrets must be encrypted at rest and must never be displayed back to the user. +**Scale/Scope**: Admin workflow; correctness + auditability prioritized. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: Wizard renders from stored state only (tenant fields + last run summaries), not live Graph. +- Graph contract path: Any Graph verification work (when implemented) must go through the existing Graph abstraction and contract registry. +- RBAC-UX: tenant-plane `/admin` only; non-member access is 404; member missing capability is 403; UI disabled state is not authorization. +- Run observability: all verification actions create/reuse an `OperationRun` and enqueue work; no synchronous external calls. +- Data minimization: onboarding session payload excludes secrets; failures are stable codes + sanitized messages. + +Status: PASS. + +## Project Structure + +### Documentation (this feature) + +```text +specs/069-managed-tenant-onboarding-wizard/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +└── contracts/ + └── onboarding-wizard.md +``` + +### Source Code (repository root) +```text +app/ +├── Filament/ # Filament resources/pages +├── Models/ # Eloquent models +├── Providers/Filament/ # Panel providers +├── Services/ # OperationRun + provider gates + auth resolvers +└── Support/ # RBAC helpers, middleware, capability registry + +bootstrap/ +└── providers.php # Laravel 11+ provider registration + +config/ +└── graph_contracts.php + +database/ +└── migrations/ + +routes/ +└── web.php + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel monolith; Filament v5 discovery conventions for pages/resources. + +## Phase 0 — Outline & Research + +Output: [research.md](research.md) + +All NEEDS CLARIFICATION items: none remaining. + +## Phase 1 — Design & Contracts + +Outputs: +- [data-model.md](data-model.md) +- [contracts/onboarding-wizard.md](contracts/onboarding-wizard.md) +- [quickstart.md](quickstart.md) + +Agent context update required after these artifacts: +- Run `.specify/scripts/bash/update-agent-context.sh copilot` + +Constitution re-check (post-design): PASS. + +## Phase 2 — Task Planning (produced by `/speckit.tasks`) + +Planned task groups: +1. Data layer: `tenant_onboarding_sessions` migration + model. +2. Wizard UI: tenant-plane Filament page with 5 steps (DB-only render). +3. RBAC mapping (canonical registry): + - Start/resume onboarding (spec: `managed_tenants.create`) → `Capabilities::TENANT_MANAGE` + - Manage onboarding fields/credentials (spec: `managed_tenants.manage`) → `Capabilities::TENANT_MANAGE` + - View tenant + wizard (spec: `managed_tenants.view`) → `Capabilities::TENANT_VIEW` + - Enqueue provider connection checks / verification runs (spec: `operations.run`) → `Capabilities::PROVIDER_RUN` + - Enqueue inventory sync (optional) → `Capabilities::TENANT_INVENTORY_SYNC_RUN` +4. Operations: enqueue-only verification action(s) backed by `OperationRunService`. +5. Legacy routes: redirect `/admin/new` to the existing “Choose tenant” entry point (`/admin/choose-tenant`). +6. Tests (Pest): resume/dedupe, RBAC 404/403 behavior, and run creation/dedupe. + +## Complexity Tracking + +N/A — no constitution violations require justification. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/069-managed-tenant-onboarding-wizard/quickstart.md b/specs/069-managed-tenant-onboarding-wizard/quickstart.md new file mode 100644 index 0000000..46182c8 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/quickstart.md @@ -0,0 +1,24 @@ +# Quickstart — Managed Tenant Onboarding Wizard v1 + +## Goal +Add a tenant-plane onboarding wizard under the `/admin` panel that: +- renders DB-only (no outbound calls during render/mount) +- enqueues verification checks as `OperationRun` records +- supports resume via a persisted onboarding session + +## Local dev +- Start containers: `vendor/bin/sail up -d` +- Run tests (targeted): `vendor/bin/sail artisan test --compact --filter=Onboarding` + +## Key entrypoint +- Tenant-plane wizard URL shape: + - `/admin/t/{tenant:external_id}/onboarding` + +## Operational checks +- Wizard-triggered checks must create/dedupe `OperationRun` rows. +- The UI should poll/read DB state to display progress (no live Graph calls from render). + +## RBAC semantics +- Non-member tenant access: 404 (deny-as-not-found) +- Member missing capability: 403 on server; UI shows disabled + tooltip via `UiEnforcement` + diff --git a/specs/069-managed-tenant-onboarding-wizard/research.md b/specs/069-managed-tenant-onboarding-wizard/research.md new file mode 100644 index 0000000..60bd492 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/research.md @@ -0,0 +1,63 @@ +# Research — Managed Tenant Onboarding Wizard v1 + +This research consolidates repo-specific patterns and decisions needed to implement Spec 069 safely. + +## Decision 1 — “Managed tenant” maps to existing `Tenant` model +- Decision: Implement the onboarding wizard around the existing `App\Models\Tenant` entity. +- Rationale: The current `/admin` panel is already multi-tenant with `Tenant::class` tenancy (`AdminPanelProvider`), tenant membership rules, and tenant-scoped `OperationRun` and provider operations. +- Alternatives considered: + - Introduce a new `ManagedTenant` model: rejected for v1 because it would duplicate existing tenancy/membership and require broad refactors. + +## Decision 2 — Wizard UI implemented as a Filament page using `Step` +- Decision: Implement the onboarding flow as a Filament page (tenant-plane) that composes steps using `Filament\Schemas\Components\Wizard\Step`. +- Rationale: Repo already uses step-based wizards (`RestoreRunResource`) and Filament v5 + Livewire v4 are the established UI stack. +- Alternatives considered: + - Keep `TenantResource` simple create/edit forms and add helper text: rejected because Spec 069 requires a guided, resumable multi-step flow. + - Build a non-Filament controller + Blade wizard: rejected; would bypass consistent Filament RBAC/UX patterns. + +## Decision 3 — RBAC-UX enforcement uses existing middleware + `UiEnforcement` +- Decision: Enforce “non-member → 404, member missing capability → 403” via existing infrastructure: + - `App\Support\Middleware\DenyNonMemberTenantAccess` for tenant-scoped routes (404 for non-members). + - `App\Support\Rbac\UiEnforcement` for Filament actions (disabled + tooltip + 404/403 server-side guards). + - `App\Services\Auth\CapabilityResolver` + `App\Support\Auth\Capabilities` registry (no raw strings). +- Rationale: This matches the repo constitution and existing patterns in resources/pages. +- Alternatives considered: + - Ad-hoc `abort(403)` / `abort(404)` scattered in actions: rejected (regression risk; violates RBAC-UX-008 intent). + +## Decision 4 — DB-only render is guaranteed by strict separation +- Decision: Wizard pages render only from: + - `Tenant` fields (including encrypted credential fields that never rehydrate secrets) + - onboarding-session persisted payload (JSON) + - last completed `OperationRun` records / stored summaries +- Rationale: Constitution requires DB-only render for monitoring and operational pages; Livewire requests should not trigger Graph. +- Alternatives considered: + - “Check on mount”: rejected; would violate DB-only render. + +## Decision 5 — All checks are enqueue-only, observable via `OperationRun` +- Decision: All verification / connectivity / inventory operations triggered from the wizard create/reuse an `OperationRun` and dispatch a job. +- Rationale: `OperationRunService` provides run-identity dedupe with a DB constraint; provider scoped checks already follow this pattern via `ProviderOperationStartGate`. +- Alternatives considered: + - Synchronous checks in UI actions: rejected; violates run-observability and DB-only render intent. + +## Decision 6 — Session persistence uses a dedicated onboarding session table +- Decision: Introduce a persisted onboarding session record that stores: + - actor + timestamps + - current step + - non-secret payload JSON + - status (active/completed/abandoned) + - foreign keys to tenant (once known) +- Rationale: Spec requires resumability and dedupe (“auto-resume existing active session”). +- Alternatives considered: + - Store progress in Laravel session only: rejected (not resilient across devices, logouts, and multi-user concurrency). + +## Decision 7 — Capability naming aligns with existing registry +- Decision: Use existing canonical capability registry (`App\Support\Auth\Capabilities`) and map Spec 069 semantics to: + - start onboarding / create tenant → `Capabilities::TENANT_MANAGE` (or introduce a dedicated `tenant.create` if needed, but still via registry) + - manage credentials/config → `Capabilities::TENANT_MANAGE` + - run checks (provider operations / inventory) → `Capabilities::PROVIDER_RUN` and/or `Capabilities::TENANT_INVENTORY_SYNC_RUN` +- Rationale: Current app already enforces these capabilities widely; adding new strings is possible but must remain centralized. +- Alternatives considered: + - Introduce `managed_tenants.*` capabilities in parallel: deferred unless Spec 068 v2 requires that rename. + +## Open Questions (deferred but not blocking plan) +- Whether Spec 068 v2 introduces a separate “Workspace” model and renames `Tenant` to “ManagedTenant”. If yes, the wizard should be adapted in that refactor; the v1 implementation should keep seams (service layer + session model) to migrate cleanly. diff --git a/specs/069-managed-tenant-onboarding-wizard/spec.md b/specs/069-managed-tenant-onboarding-wizard/spec.md new file mode 100644 index 0000000..ae4df7c --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/spec.md @@ -0,0 +1,157 @@ +# Feature Specification: Managed Tenant Onboarding Wizard v1 + +**Feature Branch**: `069-managed-tenant-onboarding-wizard` +**Created**: 2026-01-31 +**Status**: Draft +**Input**: User description: "Spec 069 — Managed Tenant Onboarding Wizard v1 (Single Front Door, DB-only render, enqueue-only runs, resumable onboarding session, RBAC-UX enforcement, remove legacy entry points)." + +## Clarifications + +### Session 2026-01-31 + +- Q: Do we need to store local app credentials (client_id/client_secret) for Managed Tenants in v1? → A: Conditional — Step 3 only when a config/driver says “credentials required”. +- Q: When a user is a workspace member but lacks a capability and tries the action/server endpoint, what should the server return? → A: 403 Forbidden. +- Q: For the legacy URL /admin/new (old managed tenant create entry), where should it redirect? → A: Redirect to “Choose workspace” (then start wizard from there). +- Q: Who is allowed to resume an existing onboarding session for a Managed Tenant? → A: Any workspace member with `managed_tenants.create` (and tenant-scoped access). +- Q: If a user starts the wizard again for the same workspace + tenant ID while an active onboarding session already exists, what should happen? → A: Auto-resume the existing active session. + +## Terminology (Repository Mapping) + +- In this repository, the spec’s term **Workspace** maps to the existing **Tenant** concept (tenant-plane container + memberships). +- Capability names shown in this spec (e.g. `managed_tenants.create`) are **conceptual** for stakeholders; implementation MUST map them onto the canonical capability registry and MUST NOT introduce new raw capability strings in feature code. + +## User Scenarios & Testing *(mandatory)* + + +### User Story 1 - Onboard a managed tenant end-to-end (Priority: P1) + +As a workspace Owner, I can onboard a new Managed Tenant through a consistent, guided wizard so onboarding is repeatable and results in a tenant that is ready to run verification/health operations. + +**Why this priority**: This is the primary business outcome: reliable onboarding and operational readiness. + +**Independent Test**: Can be fully tested by completing the wizard and observing that the system marks onboarding complete and allows runs to be started. + +**Acceptance Scenarios**: + +1. **Given** a user is a workspace Owner and no Managed Tenant exists for the target tenant ID, **When** they start the wizard and complete the steps, **Then** a Managed Tenant record exists and onboarding is marked complete. +2. **Given** a user started onboarding and leaves mid-way, **When** they return, **Then** they can resume the wizard at the last completed step with their previously entered (non-secret) data. +3. **Given** a Managed Tenant already exists in the workspace with the same tenant ID, **When** the user enters that tenant ID, **Then** the wizard prevents creating a duplicate and guides the user to the existing tenant's onboarding/resume state. + +--- + +### User Story 2 - Run verification checks without blocking page loads (Priority: P2) + +As an authorized operator, I can trigger verification/health operations for a Managed Tenant so the system checks permissions and connectivity without performing external calls during page rendering. + +**Why this priority**: Operational safety and predictability; the UI must remain responsive and all outbound work must be observable. + +**Independent Test**: Can be tested by loading wizard steps (no outbound activity on render) and then triggering a verification action that creates a run. + +**Acceptance Scenarios**: + +1. **Given** a Managed Tenant is in onboarding, **When** the user clicks “Verify permissions”, **Then** a background run is queued and the page does not perform synchronous external calls. +2. **Given** the last verification run reported missing permissions, **When** the user visits the permissions step, **Then** they see the stored “Granted/Missing” status from the last run. + +--- + +### User Story 3 - RBAC-UX enforcement and safe access semantics (Priority: P3) + +As a tenant-plane user, I can only see and interact with wizard and tenant actions I am entitled to, with deny-as-not-found for non-members and server-side enforcement for every action. + +**Why this priority**: Prevents information leakage across tenants/workspaces and ensures policy-compliant enforcement. + +**Independent Test**: Can be tested by attempting to access the wizard as a non-member, and as a member lacking specific capabilities. + +**Acceptance Scenarios**: + +1. **Given** a user is not a member of the workspace scope, **When** they attempt to access the onboarding wizard or tenant pages, **Then** they receive a 404 response (deny-as-not-found). +2. **Given** a user is a member but lacks the relevant capability, **When** they view the wizard step, **Then** restricted actions are disabled with an explanatory tooltip and server-side attempts are rejected with 403. + +--- + +### Edge Cases + +- Invalid tenant ID format entered (not a UUID/GUID). +- Attempt to create a second Managed Tenant with the same tenant ID within the same workspace. +- Two users start onboarding the same Managed Tenant concurrently. +- A user loses membership/capabilities while an onboarding session is in progress. +- Verification run fails (transient error) and surfaces a stored error code/status without breaking page rendering. +- Credentials are required but not yet set; wizard shows “missing” state. +- Credentials were set previously; wizard shows “set” state without revealing secret values. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior, +or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates +(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests. +If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries. + +**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST: +- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`), +- ensure any cross-plane access is deny-as-not-found (404), +- explicitly define 404 vs 403 semantics: + - non-member / not entitled to tenant scope → 404 (deny-as-not-found) + - member but missing capability → 403 (Forbidden) +- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change, +- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code), +- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics), +- ensure destructive-like actions require confirmation (`->requiresConfirmation()`), +- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated. + +**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange) +on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages. + +**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean), +the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values. + +### Assumptions & Dependencies + +- Depends on the existing workspace + managed tenant foundations from Spec 068 v2 (including canonical naming and tenant-plane routing). +- The onboarding wizard lives in the tenant-plane admin area (not the platform/system area). +- Credential capture is required only if the product uses local credentials for managed tenants; otherwise that step is skipped/hidden. +- A single configuration/driver flag determines whether credentials are required for the current environment. +- Permission/connection status displayed in the wizard is based on stored results from the latest completed verification run. + +### Functional Requirements + +- **FR-001 (Single Front Door)**: The system MUST allow creation of a new Managed Tenant only via the onboarding wizard. +- **FR-002 (Disable Legacy Entry Points)**: The system MUST remove or disable all previous “Add Tenant/Create” entry points and MUST redirect any legacy creation URLs to an onboarding-appropriate destination. +- **FR-002a (Legacy /admin/new Redirect)**: Requests to `/admin/new` MUST NOT create a managed tenant and MUST redirect to the “Choose workspace” entry point. +- **FR-003 (DB-only Render)**: Loading any wizard step MUST NOT trigger outbound HTTP calls; step pages MUST render exclusively from persisted data (including latest known run results). +- **FR-004 (Wizard Steps)**: The wizard MUST provide 5 steps: (1) Welcome/Requirements, (2) Tenant Details, (3) App/Credentials Setup (when applicable), (4) Admin Consent & Permissions, (5) Verification / First Run. +- **FR-005 (Tenant Details Validation)**: The wizard MUST require a tenant ID (UUID/GUID) and validate its format. +- **FR-005a (Tenant Details Fields)**: The tenant details step MUST capture: display name, tenant ID (required), optional domain, and an environment label (dev/staging/prod/other). +- **FR-006 (Uniqueness)**: The system MUST prevent duplicates by enforcing uniqueness of Managed Tenant by `(workspace, tenant ID)`. +- **FR-007 (Onboarding State)**: The system MUST track onboarding state per Managed Tenant and set initial state to “onboarding” when created/updated via the wizard. +- **FR-008 (Credentials - Optional Step)**: If the product requires local credentials for managed tenants, the wizard MUST support setting them as part of onboarding. If not required, the wizard MUST skip this step. +- **FR-008b (Credentials Decision Rule)**: The wizard MUST decide whether to include the credentials step based on a single configuration/driver rule (no ad-hoc per-page checks). +- **FR-008a (Credential Fields)**: When the credentials step is applicable, it MUST allow setting a client identifier and a client secret, and MAY allow optional labeling/notes without exposing secret values. +- **FR-009 (Credentials Security)**: When credentials are used, the system MUST store secrets encrypted at rest and MUST never display secret values after they are saved; the UI MUST only show “secret set” vs “missing”. +- **FR-010 (Credentials RBAC)**: Only users with “manage” capability for managed tenants MUST be allowed to set/rotate credentials. +- **FR-011 (Runs Canonical / Enqueue-only)**: “Verify permissions”, “Check connection”, and optional “Run inventory sync” MUST enqueue background runs and MUST NOT perform external calls synchronously. +- **FR-012 (Admin Consent & Permissions UX)**: The permissions step MUST show a required permissions list, MUST display “Granted/Missing” derived from the latest completed verification run, and MUST provide a link for administrators to grant consent. +- **FR-013 (Resume / Session Persistence)**: The system MUST persist onboarding sessions and allow users to resume an in-progress onboarding flow; persisted session payload MUST exclude secrets. +- **FR-014 (Session Dedupe)**: The system MUST ensure at most one active onboarding session exists per Managed Tenant and deduplicate accordingly. +- **FR-014a (Session Dedupe Behavior)**: When a user attempts to start onboarding for a tenant with an existing active session, the system MUST reuse that session and route the user to resume it. +- **FR-015 (Completion Criteria)**: The wizard MUST mark onboarding “complete” when the Managed Tenant exists, required credentials (if applicable) are present, and the permissions verification is successful. +- **FR-016 (Resume Link)**: The Managed Tenant view MUST show a “Resume wizard” entry point when onboarding is not complete. +- **FR-016a (Resume Authorization)**: Resuming an onboarding session MUST be allowed for any workspace member who has `managed_tenants.create` within that workspace scope. +- **FR-017 (Capabilities v1)**: The system MUST support these minimum capabilities: managed_tenants.create (start wizard), managed_tenants.manage (credentials/edit), managed_tenants.view, operations.run (start verify/health/inventory runs). + +### Key Entities *(include if feature involves data)* + +- **Workspace**: Customer/organization container; owns Managed Tenants; defines membership scope. +- **Managed Tenant**: A Microsoft/Entra/Intune tenant managed within a workspace; identified by a tenant ID; includes onboarding state and metadata (display name, optional domain, environment label). +- **Onboarding Session**: A resumable onboarding state container with: workspace, optional managed tenant reference, creator, status (draft/in progress/completed/abandoned), current step, non-secret payload, last error code, timestamps. +- **Operation Run**: An observable, queued execution record for verification/health/sync actions initiated from the wizard. + +## Success Criteria *(mandatory)* + + +### Measurable Outcomes + +- **SC-001**: Workspace Owners can complete onboarding for a new Managed Tenant in under 10 minutes (excluding time waiting for admin consent). +- **SC-002**: 100% of wizard step page loads complete without initiating outbound HTTP calls (outbound activity occurs only when a user triggers a run action). +- **SC-003**: Users can resume an in-progress wizard in 2 clicks or fewer from the Managed Tenant view. +- **SC-004**: After onboarding completion, authorized users can start verification/health runs successfully for the tenant. +- **SC-005**: Non-members receive deny-as-not-found behavior (404) for tenant-plane onboarding/managed tenant pages; members lacking capabilities are prevented from performing restricted actions. diff --git a/specs/069-managed-tenant-onboarding-wizard/tasks.md b/specs/069-managed-tenant-onboarding-wizard/tasks.md new file mode 100644 index 0000000..b446d24 --- /dev/null +++ b/specs/069-managed-tenant-onboarding-wizard/tasks.md @@ -0,0 +1,213 @@ +--- + +description: "Task list for feature implementation" +--- + +# Tasks: Managed Tenant Onboarding Wizard v1 + +**Input**: Design documents from `specs/069-managed-tenant-onboarding-wizard/` +**Prerequisites**: `plan.md` (required), `spec.md` (required), plus `research.md`, `data-model.md`, `contracts/`, `quickstart.md` + +**Tests**: Required (Pest) — runtime behavior changes. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Confirm repo conventions and entry points before implementation. + +- [x] T001 Inventory existing tenant-create entry points in app/Filament/Pages/Tenancy/RegisterTenant.php and app/Filament/Resources/TenantResource.php +- [x] T002 Confirm tenant-plane routing + membership 404 middleware in app/Providers/Filament/AdminPanelProvider.php +- [x] T003 [P] Confirm provider registration location (Laravel 11+) in bootstrap/providers.php +- [x] T004 [P] Review Filament v5 page/resource/testing rules in docs/research/filament-v5-notes.md +- [x] T051 Map spec conceptual capabilities → App\Support\Auth\Capabilities constants (TENANT_VIEW/TENANT_MANAGE/PROVIDER_RUN/TENANT_INVENTORY_SYNC_RUN) and note the mapping in specs/069-managed-tenant-onboarding-wizard/plan.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared data model + operation labeling needed by all user stories. + +**⚠️ CRITICAL**: No user story work should start until these are done. + +- [x] T005 Create onboarding sessions migration in database/migrations/*_create_tenant_onboarding_sessions_table.php +- [x] T006 Create TenantOnboardingSession model in app/Models/TenantOnboardingSession.php +- [x] T007 [P] Create TenantOnboardingSession factory in database/factories/TenantOnboardingSessionFactory.php +- [x] T008 Add partial unique index for active sessions in database/migrations/*_create_tenant_onboarding_sessions_table.php +- [x] T009 Add onboarding status columns migration in database/migrations/*_add_onboarding_status_to_tenants_table.php +- [x] T010 Update Tenant model onboarding casts/accessors in app/Models/Tenant.php +- [x] T011 Register OperationCatalog label(s) for any new onboarding verification run type(s) (only if a new type is introduced) in app/Support/OperationCatalog.php +- [x] T012 Register expected duration(s) for any new onboarding verification run type(s) (only if a new type is introduced) in app/Support/OperationCatalog.php +- [x] T052 Add AuditLog coverage tasks for onboarding-sensitive actions using app/Services/Intune/AuditLogger.php (credentials set/rotate, onboarding completed) and ensure action IDs are stable + +**Checkpoint**: Foundation ready — user story work can begin. + +--- + +## Phase 3: User Story 1 — Onboard a managed tenant end-to-end (Priority: P1) 🎯 MVP + +**Goal**: Create a guided, resumable, 5-step wizard that creates/updates a Tenant without external calls. + +**Independent Test**: Complete the wizard and confirm Tenant + session state are persisted and resumable. + +### Tests for User Story 1 (required) + +- [x] T013 [P] [US1] Add wizard happy-path coverage in tests/Feature/ManagedTenantOnboardingWizardTest.php +- [x] T014 [P] [US1] Add resume + dedupe coverage in tests/Feature/ManagedTenantOnboardingWizardResumeTest.php +- [x] T015 [P] [US1] Add tenant-duplicate prevention coverage in tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php + +### Implementation for User Story 1 + +- [x] T016 [US1] Implement session persistence service in app/Services/TenantOnboardingSessionService.php +- [x] T017 [P] [US1] Create onboarding wizard page Livewire component in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T018 [P] [US1] Create onboarding wizard view in resources/views/filament/pages/tenant-onboarding-wizard.blade.php +- [x] T019 [US1] Add step definitions + per-step validation in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T020 [US1] Implement start-or-resume behavior in app/Services/TenantOnboardingSessionService.php +- [x] T021 [US1] Ensure session payload excludes secrets in app/Services/TenantOnboardingSessionService.php +- [x] T022 [US1] Implement tenant creation/update (DB-only) in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T023 [US1] Enforce uniqueness by tenant_id (repository “workspace” == Tenant container; tenant_id is the unique external key) in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T024 [US1] Add “credentials required” decision rule config in config/tenantpilot.php +- [x] T025 [US1] Apply credentials-step conditional rendering in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T026 [US1] Ensure secrets never re-render (only “set/missing”) in resources/views/filament/pages/tenant-onboarding-wizard.blade.php +- [x] T027 [US1] Add “Resume wizard” action to tenant view in app/Filament/Resources/TenantResource.php +- [x] T028 [US1] Remove/disable non-wizard tenant creation entry in app/Filament/Pages/Tenancy/RegisterTenant.php +- [x] T029 [US1] Remove/disable TenantResource create flow entry in app/Filament/Resources/TenantResource.php + +**Checkpoint**: US1 complete — wizard works end-to-end, resumable, DB-only. + +--- + +## Phase 4: User Story 2 — Run verification checks without blocking page loads (Priority: P2) + +**Goal**: Trigger verification via enqueue-only `OperationRun` and display stored results (no Graph calls during render). + +**Independent Test**: Load wizard step pages without outbound calls; click Verify → `OperationRun` created and job enqueued. + +### Tests for User Story 2 (required) + +- [x] T030 [P] [US2] Assert wizard render/mount is DB-only by binding a failing fake GraphClientInterface (or equivalent Graph abstraction) in tests/Feature/ManagedTenantOnboardingWizardDbOnlyRenderTest.php +- [x] T031 [P] [US2] Assert Verify creates/dedupes OperationRun in tests/Feature/TenantOnboardingVerifyOperationRunTest.php +- [x] T032 [P] [US2] Assert permissions step uses stored results in tests/Feature/ManagedTenantOnboardingWizardPermissionsViewTest.php + +### Implementation for User Story 2 + +- [x] T033 [US2] Ensure any wizard-triggered verification action is enqueue-only (creates/reuses OperationRun + dispatches job) and never calls Graph during render/mount in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T034 [US2] Wire “Check connection” to the existing provider.connection.check operation (OperationRun type + existing job patterns) and render stored outcome in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T035 [US2] Implement run creation + dedupe for onboarding verification (permissions/RBAC) in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T036 [US2] Create onboarding verification job (Graph calls allowed only inside job via GraphClientInterface + contracts) in app/Jobs/TenantOnboardingVerifyJob.php +- [x] T037 [US2] Dispatch TenantOnboardingVerifyJob only when run is newly created and persist sanitized results to tenant fields in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T038 [US2] Render stored “Granted/Missing” status in resources/views/filament/pages/tenant-onboarding-wizard.blade.php +- [x] T039 [US2] Implement completion criteria check based on stored results in app/Filament/Pages/TenantOnboardingWizard.php + +**Checkpoint**: US2 complete — verification is observable + async; UI shows stored results. + +--- + +## Phase 5: User Story 3 — RBAC-UX enforcement and safe access semantics (Priority: P3) + +**Goal**: Enforce 404 vs 403 semantics and ensure UI is disabled+tooltip for insufficient capabilities. + +**Independent Test**: Non-member gets 404; member w/out capability sees disabled UI and server rejects with 403. + +### Tests for User Story 3 (required) + +- [x] T040 [P] [US3] Assert non-member wizard access is 404 in tests/Feature/ManagedTenantOnboardingWizardRbacTest.php +- [x] T041 [P] [US3] Assert member missing capability is 403 on actions in tests/Feature/ManagedTenantOnboardingWizardRbacTest.php +- [x] T042 [P] [US3] Assert disabled UI state is rendered for insufficient capability in tests/Feature/ManagedTenantOnboardingWizardUiEnforcementTest.php + +### Implementation for User Story 3 + +- [x] T043 [US3] Wrap wizard actions with UiEnforcement in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T044 [US3] Enforce server-side Gate authorization in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T045 [US3] Ensure wizard page is not registered in nav (entry-point only) in app/Filament/Pages/TenantOnboardingWizard.php +- [x] T046 [US3] Ensure credential mutation actions require confirmation in app/Filament/Pages/TenantOnboardingWizard.php + +**Checkpoint**: US3 complete — RBAC semantics are enforced and regression-tested. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [x] T047 Add legacy redirect for /admin/new → /admin/choose-tenant in routes/web.php +- [x] T048 Add/verify onboarding “single front door” UX copy in resources/views/filament/pages/tenant-onboarding-wizard.blade.php +- [x] T049 [P] Run formatter on touched files via `vendor/bin/sail bin pint --dirty` (targets app/ and tests/) +- [x] T050 Run focused test suite via `vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php` +- [x] T053 Add at least one positive authorization test (member with required capability can start/resume/verify) alongside the negative 404/403 tests in tests/Feature/ManagedTenantOnboardingWizardRbacTest.php + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- Phase 1 (Setup) → Phase 2 (Foundational) → User story phases. + +### User Story Dependencies (graph) + +- US1 (P1) → US2 (P2) → US3 (P3) + - US2 depends on US1 having the wizard + tenant/session persistence. + - US3 can be implemented alongside US1/US2 but must land with tests. + +### Parallel opportunities + +- Setup: T003–T004 can run in parallel. +- Foundational: T007 can run in parallel with T005–T006. +- US1 tests (T013–T015) can be authored in parallel. +- US2 tests (T030–T032) can be authored in parallel. +- US3 tests (T040–T042) can be authored in parallel. + +--- + +## Parallel Example: User Story 1 + +```bash +# Tests in parallel +T013 # tests/Feature/ManagedTenantOnboardingWizardTest.php +T014 # tests/Feature/ManagedTenantOnboardingWizardResumeTest.php +T015 # tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php + +# UI + service split +T016 # app/Services/TenantOnboardingSessionService.php +T017 # app/Filament/Pages/TenantOnboardingWizard.php +T018 # resources/views/filament/pages/tenant-onboarding-wizard.blade.php +``` + +## Parallel Example: User Story 2 + +```bash +# Tests in parallel +T030 # tests/Feature/ManagedTenantOnboardingWizardDbOnlyRenderTest.php +T031 # tests/Feature/TenantOnboardingVerifyOperationRunTest.php +T032 # tests/Feature/ManagedTenantOnboardingWizardPermissionsViewTest.php + +# Job + UI work split +T036 # app/Jobs/TenantOnboardingVerifyJob.php +T035 # app/Filament/Pages/TenantOnboardingWizard.php +T038 # resources/views/filament/pages/tenant-onboarding-wizard.blade.php +``` + +## Parallel Example: User Story 3 + +```bash +# Tests in parallel +T040 # tests/Feature/ManagedTenantOnboardingWizardRbacTest.php +T042 # tests/Feature/ManagedTenantOnboardingWizardUiEnforcementTest.php + +# Enforcement +T043 # app/Filament/Pages/TenantOnboardingWizard.php +``` + +--- + +## Implementation Strategy + +### MVP scope + +- MVP = US1 only (wizard + session persistence + single front door). + +### Incremental delivery + +1. Setup + Foundational. +2. Deliver US1 (MVP) and validate independently. +3. Add US2 (enqueue-only verification) and validate independently. +4. Add US3 (RBAC-UX hardening + regression tests). diff --git a/tests/Feature/AdminNewRedirectTest.php b/tests/Feature/AdminNewRedirectTest.php new file mode 100644 index 0000000..004ff2b --- /dev/null +++ b/tests/Feature/AdminNewRedirectTest.php @@ -0,0 +1,8 @@ +get('/admin/new') + ->assertRedirect('/admin/choose-tenant'); +}); diff --git a/tests/Feature/BulkSyncPoliciesTest.php b/tests/Feature/BulkSyncPoliciesTest.php index 74b990b..e8a92af 100644 --- a/tests/Feature/BulkSyncPoliciesTest.php +++ b/tests/Feature/BulkSyncPoliciesTest.php @@ -11,6 +11,8 @@ uses(RefreshDatabase::class); test('policy sync updates selected policies from graph and updates the operation run', function () { + config()->set('graph.enabled', true); + $tenant = Tenant::factory()->create([ 'status' => 'active', ]); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index 1d0ff22..406d3ed 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -1,6 +1,5 @@ fillForm([ - 'name' => 'Contoso', - 'environment' => 'other', - 'tenant_id' => 'tenant-guid', - 'domain' => 'contoso.com', - 'app_client_id' => 'client-123', - 'app_notes' => 'Test tenant', - ]) - ->call('create') - ->assertHasNoFormErrors(); + $tenant = Tenant::create([ + 'name' => 'Contoso', + 'environment' => 'other', + 'tenant_id' => 'tenant-guid', + 'domain' => 'contoso.com', + 'app_client_id' => 'client-123', + 'app_notes' => 'Test tenant', + ]); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); - $tenant = Tenant::query()->where('tenant_id', 'tenant-guid')->first(); expect($tenant)->not->toBeNull(); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) diff --git a/tests/Feature/ManagedTenantOnboardingWizardCredentialsConfirmationTest.php b/tests/Feature/ManagedTenantOnboardingWizardCredentialsConfirmationTest.php new file mode 100644 index 0000000..0c167a7 --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardCredentialsConfirmationTest.php @@ -0,0 +1,35 @@ +set('tenantpilot.onboarding.credentials_required', true); + + [$user, $portfolioTenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenantGuid = fake()->uuid(); + + Livewire::withQueryParams([ + 'tenant' => (string) $portfolioTenant->external_id, + ])->test(TenantOnboardingWizard::class) + ->goToNextWizardStep() + ->fillForm([ + 'name' => 'Acme Corp', + 'environment' => 'prod', + 'tenant_id' => $tenantGuid, + 'domain' => 'acme.example', + ], 'form') + ->goToNextWizardStep() + ->fillForm([ + 'app_client_id' => fake()->uuid(), + 'app_client_secret' => 'super-secret-value', + 'acknowledge_credentials' => false, + ], 'form') + ->goToNextWizardStep() + ->assertHasFormErrors(['acknowledge_credentials']); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardDbOnlyRenderTest.php b/tests/Feature/ManagedTenantOnboardingWizardDbOnlyRenderTest.php new file mode 100644 index 0000000..cef4f9a --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardDbOnlyRenderTest.php @@ -0,0 +1,19 @@ +actingAs($user); + + Livewire::withQueryParams([ + 'tenant' => (string) $portfolioTenant->external_id, + ])->test(TenantOnboardingWizard::class) + ->assertStatus(200); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php b/tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php new file mode 100644 index 0000000..68e9d74 --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php @@ -0,0 +1,42 @@ +set('tenantpilot.onboarding.credentials_required', false); + + [$user, $portfolioTenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenantGuid = fake()->uuid(); + + $existing = Tenant::factory()->create([ + 'name' => 'Already Exists', + 'tenant_id' => $tenantGuid, + 'environment' => 'prod', + 'status' => 'active', + ]); + + $user->tenants()->syncWithoutDetaching([ + $existing->getKey() => ['role' => 'owner'], + ]); + + Livewire::withQueryParams([ + 'tenant' => (string) $portfolioTenant->external_id, + ])->test(TenantOnboardingWizard::class) + ->goToNextWizardStep() + ->fillForm([ + 'name' => 'Attempt Duplicate', + 'environment' => 'prod', + 'tenant_id' => $tenantGuid, + 'domain' => 'dup.example', + ], 'form') + ->goToNextWizardStep(); + + expect(Tenant::query()->where('tenant_id', $tenantGuid)->count())->toBe(1); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardPermissionsViewTest.php b/tests/Feature/ManagedTenantOnboardingWizardPermissionsViewTest.php new file mode 100644 index 0000000..88f192c --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardPermissionsViewTest.php @@ -0,0 +1,51 @@ +set('tenantpilot.onboarding.credentials_required', false); + + config()->set('intune_permissions.permissions', [[ + 'key' => 'DeviceManagementApps.Read.All', + 'type' => 'application', + 'description' => 'Read apps', + 'features' => ['apps'], + ]]); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + TenantPermission::create([ + 'tenant_id' => $tenant->getKey(), + 'permission_key' => 'DeviceManagementApps.Read.All', + 'status' => 'granted', + 'details' => ['source' => 'test'], + 'last_checked_at' => now(), + ]); + + TenantOnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'created_by_user_id' => $user->getKey(), + 'status' => 'active', + 'current_step' => 'permissions', + 'payload' => [ + 'tenant_id' => $tenant->tenant_id, + 'environment' => $tenant->environment, + 'name' => $tenant->name, + ], + ]); + + Livewire::withQueryParams([ + 'tenant' => (string) $tenant->external_id, + ]) + ->test(TenantOnboardingWizard::class) + ->assertSee('Required permissions') + ->assertSee('DeviceManagementApps.Read.All') + ->assertSee('Granted'); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardRbacTest.php b/tests/Feature/ManagedTenantOnboardingWizardRbacTest.php new file mode 100644 index 0000000..4d00168 --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardRbacTest.php @@ -0,0 +1,52 @@ +create(); + + $this->actingAs($outsider) + ->get('/admin/tenant-onboarding?tenant='.(string) $tenant->external_id) + ->assertNotFound(); +}); + +it('returns 403 when a member lacks provider run capability and tries to enqueue verification', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + + Livewire::withQueryParams([ + 'tenant' => (string) $tenant->external_id, + ]) + ->test(TenantOnboardingWizard::class) + ->call('enqueueVerification') + ->assertForbidden(); +}); + +it('allows an owner to enqueue verification', function (): void { + Bus::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + Livewire::withQueryParams([ + 'tenant' => (string) $tenant->external_id, + ]) + ->test(TenantOnboardingWizard::class) + ->call('enqueueVerification') + ->assertSuccessful(); + + expect(OperationRun::query()->where('type', 'tenant.rbac.verify')->count())->toBe(1); + + Bus::assertDispatched(TenantOnboardingVerifyJob::class, 1); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardResumeTest.php b/tests/Feature/ManagedTenantOnboardingWizardResumeTest.php new file mode 100644 index 0000000..a7356c5 --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardResumeTest.php @@ -0,0 +1,51 @@ +set('tenantpilot.onboarding.credentials_required', false); + + [$user] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenant = Tenant::factory()->create([ + 'name' => 'Existing Tenant', + 'tenant_id' => fake()->uuid(), + 'environment' => 'other', + 'status' => 'active', + 'onboarding_status' => 'in_progress', + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'owner'], + ]); + + $existingSession = TenantOnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'created_by_user_id' => $user->getKey(), + 'status' => 'active', + 'current_step' => 'permissions', + 'payload' => [ + 'name' => $tenant->name, + 'tenant_id' => $tenant->tenant_id, + 'environment' => $tenant->environment, + ], + ]); + + $component = Livewire::withQueryParams([ + 'tenant' => (string) $tenant->external_id, + ])->test(TenantOnboardingWizard::class); + + expect($component->get('sessionId'))->toBe((string) $existingSession->getKey()); + + expect(TenantOnboardingSession::query() + ->where('tenant_id', $tenant->getKey()) + ->where('status', 'active') + ->count())->toBe(1); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardTest.php b/tests/Feature/ManagedTenantOnboardingWizardTest.php new file mode 100644 index 0000000..6964387 --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -0,0 +1,55 @@ +set('tenantpilot.onboarding.credentials_required', true); + + [$user, $portfolioTenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenantGuid = fake()->uuid(); + + $component = Livewire::withQueryParams([ + 'tenant' => (string) $portfolioTenant->external_id, + ])->test(TenantOnboardingWizard::class); + + $component + ->assertStatus(200) + ->goToNextWizardStep() + ->fillForm([ + 'name' => 'Acme Corp', + 'environment' => 'prod', + 'tenant_id' => $tenantGuid, + 'domain' => 'acme.example', + ], 'form') + ->goToNextWizardStep() + ->fillForm([ + 'app_client_id' => fake()->uuid(), + 'app_client_secret' => 'super-secret-value', + 'app_certificate_thumbprint' => null, + 'app_notes' => 'Created via onboarding wizard', + 'acknowledge_credentials' => true, + ], 'form') + ->goToNextWizardStep(); + + $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->first(); + + expect($tenant)->toBeInstanceOf(Tenant::class); + expect($tenant->onboarding_status)->toBe('in_progress'); + + expect($user->tenants()->whereKey($tenant->getKey())->exists())->toBeTrue(); + + $session = TenantOnboardingSession::query()->where('tenant_id', $tenant->getKey())->first(); + + expect($session)->toBeInstanceOf(TenantOnboardingSession::class); + expect($session->status)->toBe('active'); + expect($session->payload)->toBeArray(); + expect($session->payload)->not->toHaveKey('app_client_secret'); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardUiEnforcementTest.php b/tests/Feature/ManagedTenantOnboardingWizardUiEnforcementTest.php new file mode 100644 index 0000000..a9d3cfa --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardUiEnforcementTest.php @@ -0,0 +1,35 @@ +set('tenantpilot.onboarding.credentials_required', false); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + TenantOnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'created_by_user_id' => $user->getKey(), + 'status' => 'active', + 'current_step' => 'permissions', + 'payload' => [ + 'tenant_id' => $tenant->tenant_id, + 'environment' => $tenant->environment, + 'name' => $tenant->name, + ], + ]); + + $this->actingAs($user); + + Livewire::withQueryParams([ + 'tenant' => (string) $tenant->external_id, + ]) + ->test(TenantOnboardingWizard::class) + ->assertSee('Verify permissions') + ->assertSee('You do not have permission to run provider operations.') + ->assertSeeHtml('disabled'); +}); diff --git a/tests/Feature/ManagedTenantOnboardingWizardVerificationReadyTest.php b/tests/Feature/ManagedTenantOnboardingWizardVerificationReadyTest.php new file mode 100644 index 0000000..b116d0c --- /dev/null +++ b/tests/Feature/ManagedTenantOnboardingWizardVerificationReadyTest.php @@ -0,0 +1,76 @@ +set('tenantpilot.onboarding.credentials_required', false); + + config()->set('intune_permissions.permissions', [[ + 'key' => 'DeviceManagementApps.Read.All', + 'type' => 'application', + 'description' => 'Read apps', + 'features' => ['apps'], + ]]); + + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + 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' => 'connected', + 'health_status' => 'ok', + 'scopes_granted' => [], + 'metadata' => [], + ]); + + TenantPermission::create([ + 'tenant_id' => $tenant->getKey(), + 'permission_key' => 'DeviceManagementApps.Read.All', + 'status' => 'granted', + 'details' => ['source' => 'test'], + 'last_checked_at' => now(), + ]); + + OperationRun::query()->create([ + 'tenant_id' => $tenant->getKey(), + 'user_id' => $user->getKey(), + 'initiator_name' => $user->name, + 'type' => 'tenant.rbac.verify', + 'status' => 'completed', + 'outcome' => 'succeeded', + 'run_identity_hash' => 'test', + 'summary_counts' => [], + 'failure_summary' => [], + 'context' => [], + ]); + + TenantOnboardingSession::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'created_by_user_id' => $user->getKey(), + 'status' => 'active', + 'current_step' => 'verification', + 'payload' => [ + 'tenant_id' => $tenant->tenant_id, + 'environment' => $tenant->environment, + 'name' => $tenant->name, + ], + ]); + + Livewire::withQueryParams([ + 'tenant' => (string) $tenant->external_id, + ]) + ->test(TenantOnboardingWizard::class) + ->assertSee('Ready:'); +}); diff --git a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php index 9a1871f..edb3c47 100644 --- a/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php +++ b/tests/Feature/Rbac/RegisterTenantAuthorizationTest.php @@ -18,6 +18,6 @@ $this->actingAs($user); $tenant->makeCurrent(); - expect(RegisterTenant::canView())->toBeTrue(); + expect(RegisterTenant::canView())->toBeFalse(); }); }); diff --git a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php index 281fdbc..3166ffd 100644 --- a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php +++ b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php @@ -1,7 +1,7 @@ actingAs($user); Livewire::actingAs($user) - ->test(CreateTenant::class) + ->test(TenantOnboardingWizard::class) ->assertStatus(403); }); diff --git a/tests/Feature/TenantOnboardingAuditServiceTest.php b/tests/Feature/TenantOnboardingAuditServiceTest.php new file mode 100644 index 0000000..feb542b --- /dev/null +++ b/tests/Feature/TenantOnboardingAuditServiceTest.php @@ -0,0 +1,45 @@ +create(); + $actor = User::factory()->create(); + + $service = app(TenantOnboardingAuditService::class); + + $service->credentialsUpdated($tenant, $actor, [ + 'app_client_id_set' => true, + 'client_secret' => 'should-not-be-stored', + ]); + + $audit = AuditLog::query()->latest('id')->firstOrFail(); + + expect($audit->action)->toBe(AuditActions::TENANT_ONBOARDING_CREDENTIALS_UPDATED); + expect($audit->tenant_id)->toBe($tenant->id); + expect($audit->actor_id)->toBe($actor->id); + expect($audit->metadata)->toMatchArray([ + 'app_client_id_set' => true, + ]); + expect($audit->metadata)->not->toHaveKey('client_secret'); +}); + +it('logs onboarding completion with a stable action id', function () { + $tenant = Tenant::factory()->create(); + $actor = User::factory()->create(); + + $service = app(TenantOnboardingAuditService::class); + + $service->onboardingCompleted($tenant, $actor, [ + 'onboarding_status' => 'completed', + ]); + + $audit = AuditLog::query()->latest('id')->firstOrFail(); + + expect($audit->action)->toBe(AuditActions::TENANT_ONBOARDING_COMPLETED); + expect($audit->metadata['onboarding_status'])->toBe('completed'); +}); diff --git a/tests/Feature/TenantOnboardingConnectionCheckOperationRunTest.php b/tests/Feature/TenantOnboardingConnectionCheckOperationRunTest.php new file mode 100644 index 0000000..2d2427e --- /dev/null +++ b/tests/Feature/TenantOnboardingConnectionCheckOperationRunTest.php @@ -0,0 +1,52 @@ +set('tenantpilot.onboarding.credentials_required', true); + + Bus::fake(); + + [$user, $portfolioTenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenantGuid = fake()->uuid(); + + $component = Livewire::withQueryParams([ + 'tenant' => (string) $portfolioTenant->external_id, + ])->test(TenantOnboardingWizard::class) + ->goToNextWizardStep() + ->fillForm([ + 'name' => 'Acme', + 'environment' => 'other', + 'tenant_id' => $tenantGuid, + 'domain' => 'acme.example', + ], 'form') + ->goToNextWizardStep() + ->fillForm([ + 'app_client_id' => fake()->uuid(), + 'app_client_secret' => 'super-secret', + 'acknowledge_credentials' => true, + ], 'form') + ->goToNextWizardStep() + ->goToNextWizardStep(); + + $component->call('enqueueConnectionCheck'); + + expect(OperationRun::query()->where('type', 'provider.connection.check')->count())->toBe(1); + + Bus::assertDispatched(ProviderConnectionHealthCheckJob::class, 1); + + $component->call('enqueueConnectionCheck'); + + expect(OperationRun::query()->where('type', 'provider.connection.check')->count())->toBe(1); + + Bus::assertDispatched(ProviderConnectionHealthCheckJob::class, 1); +}); diff --git a/tests/Feature/TenantOnboardingSessionTest.php b/tests/Feature/TenantOnboardingSessionTest.php new file mode 100644 index 0000000..99e4fc3 --- /dev/null +++ b/tests/Feature/TenantOnboardingSessionTest.php @@ -0,0 +1,32 @@ +create([ + 'payload' => ['step' => 'welcome'], + ]); + + expect($session->payload)->toBeArray(); + expect($session->payload['step'])->toBe('welcome'); +}); + +it('enforces a single active onboarding session per tenant', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(); + + TenantOnboardingSession::factory()->create([ + 'tenant_id' => $tenant->id, + 'created_by_user_id' => $user->id, + 'status' => 'active', + ]); + + expect(fn () => TenantOnboardingSession::factory()->create([ + 'tenant_id' => $tenant->id, + 'created_by_user_id' => $user->id, + 'status' => 'active', + ]))->toThrow(QueryException::class); +}); diff --git a/tests/Feature/TenantOnboardingVerifyOperationRunTest.php b/tests/Feature/TenantOnboardingVerifyOperationRunTest.php new file mode 100644 index 0000000..0f02795 --- /dev/null +++ b/tests/Feature/TenantOnboardingVerifyOperationRunTest.php @@ -0,0 +1,45 @@ +set('tenantpilot.onboarding.credentials_required', false); + + Bus::fake(); + + [$user, $portfolioTenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user); + + $tenantGuid = fake()->uuid(); + + $component = Livewire::withQueryParams([ + 'tenant' => (string) $portfolioTenant->external_id, + ])->test(TenantOnboardingWizard::class) + ->goToNextWizardStep() + ->fillForm([ + 'name' => 'Acme', + 'environment' => 'other', + 'tenant_id' => $tenantGuid, + 'domain' => 'acme.example', + ], 'form') + ->goToNextWizardStep(); + + $component->call('enqueueVerification'); + + expect(OperationRun::query()->where('type', 'tenant.rbac.verify')->count())->toBe(1); + + Bus::assertDispatched(TenantOnboardingVerifyJob::class, 1); + + $component->call('enqueueVerification'); + + expect(OperationRun::query()->where('type', 'tenant.rbac.verify')->count())->toBe(1); + + Bus::assertDispatched(TenantOnboardingVerifyJob::class, 1); +}); diff --git a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php index c8d216d..af974ed 100644 --- a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php +++ b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php @@ -1,6 +1,6 @@ false]); + $user = User::factory()->create(); $existingTenant = Tenant::factory()->create(); $user->tenants()->syncWithoutDetaching([ @@ -21,12 +23,17 @@ $tenantGuid = '11111111-1111-1111-1111-111111111111'; - Livewire::test(RegisterTenant::class) - ->set('data.name', 'Acme') - ->set('data.environment', 'other') - ->set('data.tenant_id', $tenantGuid) - ->set('data.domain', 'acme.example') - ->call('register'); + Livewire::withQueryParams([ + 'tenant' => (string) $existingTenant->external_id, + ])->test(TenantOnboardingWizard::class) + ->goToNextWizardStep() + ->fillForm([ + 'name' => 'Acme', + 'environment' => 'other', + 'tenant_id' => $tenantGuid, + 'domain' => 'acme.example', + ], 'form') + ->goToNextWizardStep(); $tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();