Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
31376c422e wip: save 069 onboarding wizard v1 worktree state 2026-02-01 12:20:09 +01:00
47 changed files with 2888 additions and 47 deletions

View File

@ -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]
<!-- MANUAL ADDITIONS START -->

View File

@ -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

View File

@ -0,0 +1,820 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\Tenant;
use App\Models\TenantPermission;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Jobs\TenantOnboardingVerifyJob;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\TenantOnboardingAuditService;
use App\Services\TenantOnboardingSessionService;
use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService;
use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunStatus;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Illuminate\Database\QueryException;
use Illuminate\Support\Str;
class TenantOnboardingWizard extends Page implements HasForms
{
use InteractsWithForms;
protected static bool $shouldRegisterNavigation = false;
protected static bool $isDiscovered = false;
protected static ?string $slug = 'tenant-onboarding';
protected static ?string $title = 'Tenant onboarding';
protected string $view = 'filament.pages.tenant-onboarding-wizard';
/**
* @var array<string, mixed>
*/
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<int, Step>
*/
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<int, array{key:string,type:string,description:?string,features:array<int,string>,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<int, string>
*/
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();
}
}

View File

@ -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();

View File

@ -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.'),
];
}
}

View File

@ -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')

View File

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Services\TenantOnboardingAuditService;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
class TenantOnboardingVerifyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public ?OperationRun $operationRun = null;
public function __construct(
public int $tenantId,
public int $userId,
?OperationRun $operationRun = null,
) {
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
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.',
]],
);
}
}

View File

@ -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);

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantOnboardingSession extends Model
{
/** @use HasFactory<\Database\Factories\TenantOnboardingSessionFactory> */
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');
}
}

View File

@ -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')

View File

@ -0,0 +1,68 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Support\Audit\AuditActions;
use Illuminate\Support\Arr;
class TenantOnboardingAuditService
{
public function __construct(public AuditLogger $auditLogger)
{
}
public function credentialsUpdated(Tenant $tenant, ?User $actor = null, array $context = []): AuditLog
{
$context = $this->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<string, mixed> $context
* @return array<string, mixed>
*/
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);
}
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use Illuminate\Database\QueryException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class TenantOnboardingSessionService
{
/**
* Start a new onboarding session, or resume an existing active session.
*/
public function startOrResume(User $user, ?Tenant $tenant = null): TenantOnboardingSession
{
if ($tenant instanceof Tenant) {
$existing = TenantOnboardingSession::query()
->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<string, mixed> $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<string, mixed> $payload
* @return array<string, mixed>
*/
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<string, mixed> $payload
* @param array<int, string> $forbiddenKeys
* @return array<string, mixed>
*/
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');
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Support\Audit;
final class AuditActions
{
public const TENANT_ONBOARDING_CREDENTIALS_UPDATED = 'tenant.onboarding.credentials.updated';
public const TENANT_ONBOARDING_COMPLETED = 'tenant.onboarding.completed';
}

View File

@ -35,6 +35,7 @@ public static function labels(): array
'restore_run.restore' => '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,

View File

@ -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',

View File

@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TenantOnboardingSession>
*/
class TenantOnboardingSessionFactory extends Factory
{
protected $model = TenantOnboardingSession::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('tenant_onboarding_sessions', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -0,0 +1,126 @@
<x-filament-panels::page>
<div class="flex flex-col gap-6">
@php($canRunProviderOps = $this->canRunProviderOperations())
<div class="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="flex flex-col gap-2">
<div class="font-medium text-gray-900 dark:text-gray-100">Tenant onboarding wizard</div>
<div>
This is the single supported entry point for creating and onboarding tenants.
You can safely close this page and resume later.
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
Note: the legacy “create tenant” screens are intentionally disabled to keep onboarding consistent and auditable.
</div>
</div>
</div>
@if ($this->tenantId)
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200">
<div class="flex flex-col gap-2">
<div>
<span class="font-medium">Session:</span> {{ $this->sessionId }}
</div>
<div>
<span class="font-medium">Client secret:</span>
{{ $this->tenantHasClientSecret() ? 'set' : 'missing' }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
If you need to resume later, open this wizard again from the tenants “Resume onboarding” action.
</div>
</div>
</div>
@endif
@if ($this->tenantId && $this->currentStep === 'permissions')
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Required permissions</div>
<x-filament::button
size="sm"
color="primary"
wire:click="enqueueVerification"
:disabled="! $canRunProviderOps"
:title="$canRunProviderOps ? null : 'You do not have permission to run provider operations.'"
>
Verify permissions
</x-filament::button>
</div>
@php($runStatus = $this->latestVerificationRunStatus())
@if ($runStatus)
<div class="text-xs text-gray-600 dark:text-gray-300">
Last verification run status: <span class="font-medium">{{ $runStatus }}</span>
</div>
@endif
<div class="flex flex-col gap-2">
@forelse ($this->permissionRows() as $permission)
<div class="flex items-center justify-between gap-4 rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm dark:border-gray-800 dark:bg-gray-950">
<div class="min-w-0">
<div class="truncate font-mono text-xs text-gray-900 dark:text-gray-100">{{ $permission['key'] }}</div>
@if (! empty($permission['description']))
<div class="truncate text-xs text-gray-600 dark:text-gray-300">{{ $permission['description'] }}</div>
@endif
</div>
@php($status = (string) ($permission['status'] ?? 'missing'))
<span @class([
'inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium',
'bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' => $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) }}
</span>
</div>
@empty
<div class="text-sm text-gray-600 dark:text-gray-300">
No required permissions are configured.
</div>
@endforelse
</div>
</div>
</div>
@endif
@if ($this->tenantId && $this->currentStep === 'verification')
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Verification</div>
<x-filament::button
size="sm"
color="success"
wire:click="enqueueConnectionCheck"
:disabled="! $canRunProviderOps"
:title="$canRunProviderOps ? null : 'You do not have permission to run provider operations.'"
>
Check connection
</x-filament::button>
</div>
@php($connectionRunStatus = $this->latestConnectionCheckRunStatus())
@if ($connectionRunStatus)
<div class="text-xs text-gray-600 dark:text-gray-300">
Last connection check status: <span class="font-medium">{{ $connectionRunStatus }}</span>
</div>
@endif
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">
@if ($this->isReadyToCompleteOnboarding())
<span class="font-medium">Ready:</span> all stored checks look good.
@else
<span class="font-medium">Not ready yet:</span> run checks and ensure permissions are granted.
@endif
</div>
</div>
</div>
@endif
<form wire:submit.prevent>
{{ $this->form }}
</form>
</div>
</x-filament-panels::page>

View File

@ -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');

View File

@ -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.

View File

@ -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.

View File

@ -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 hasnt 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

View File

@ -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] |

View File

@ -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`

View File

@ -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.

View File

@ -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 specs 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.

View File

@ -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: T003T004 can run in parallel.
- Foundational: T007 can run in parallel with T005T006.
- US1 tests (T013T015) can be authored in parallel.
- US2 tests (T030T032) can be authored in parallel.
- US3 tests (T040T042) 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).

View File

@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
it('redirects /admin/new to /admin/choose-tenant', function (): void {
$this->get('/admin/new')
->assertRedirect('/admin/choose-tenant');
});

View File

@ -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',
]);

View File

@ -1,6 +1,5 @@
<?php
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Models\Tenant;
use App\Models\TenantPermission;
@ -64,19 +63,18 @@ public function request(string $method, string $path, array $options = []): Grap
]);
Filament::setTenant($contextTenant, true);
Livewire::test(CreateTenant::class)
->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()])

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use Livewire\Livewire;
it('requires acknowledgement before saving credentials', function (): void {
config()->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']);
});

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use Livewire\Livewire;
it('does not invoke Graph client during wizard mount/render', function (): void {
bindFailHardGraphClient();
[$user, $portfolioTenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
Livewire::withQueryParams([
'tenant' => (string) $portfolioTenant->external_id,
])->test(TenantOnboardingWizard::class)
->assertStatus(200);
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\Tenant;
use Livewire\Livewire;
it('prevents creating a duplicate tenant for the same tenant_id', function (): void {
config()->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);
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\TenantOnboardingSession;
use App\Models\TenantPermission;
use Livewire\Livewire;
it('renders stored permission statuses on the permissions step', function (): void {
config()->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');
});

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Jobs\TenantOnboardingVerifyJob;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
it('returns 404 when a user is not a member of the tenant', function (): void {
[$member, $tenant] = createUserWithTenant(role: 'owner');
$outsider = \App\Models\User::factory()->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);
});

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use Livewire\Livewire;
it('resumes an active session for the same tenant instead of creating a new one', function (): void {
config()->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);
});

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use Livewire\Livewire;
it('creates a tenant and persists onboarding session state', function (): void {
config()->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');
});

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\TenantOnboardingSession;
use Livewire\Livewire;
it('renders disabled provider operation buttons for members without capability', function (): void {
config()->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');
});

View File

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\OperationRun;
use App\Models\ProviderConnection;
use App\Models\TenantOnboardingSession;
use App\Models\TenantPermission;
use Livewire\Livewire;
it('shows ready state when stored checks indicate success', function (): void {
config()->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:');
});

View File

@ -18,6 +18,6 @@
$this->actingAs($user);
$tenant->makeCurrent();
expect(RegisterTenant::canView())->toBeTrue();
expect(RegisterTenant::canView())->toBeFalse();
});
});

View File

@ -1,7 +1,7 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use App\Filament\Pages\TenantOnboardingWizard;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@ -25,6 +25,6 @@
$this->actingAs($user);
Livewire::actingAs($user)
->test(CreateTenant::class)
->test(TenantOnboardingWizard::class)
->assertStatus(403);
});

View File

@ -0,0 +1,45 @@
<?php
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\User;
use App\Services\TenantOnboardingAuditService;
use App\Support\Audit\AuditActions;
it('logs credential updates without storing secrets', function () {
$tenant = Tenant::factory()->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');
});

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
it('creates and dedupes a provider connection check OperationRun and dispatches a job', function (): void {
config()->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);
});

View File

@ -0,0 +1,32 @@
<?php
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use Illuminate\Database\QueryException;
it('casts payload as array', function () {
$session = TenantOnboardingSession::factory()->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);
});

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\TenantOnboardingWizard;
use App\Jobs\TenantOnboardingVerifyJob;
use App\Models\OperationRun;
use Illuminate\Support\Facades\Bus;
use Livewire\Livewire;
it('creates and dedupes a verification OperationRun and dispatches a job', function (): void {
config()->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);
});

View File

@ -1,6 +1,6 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Pages\TenantOnboardingWizard;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\TenantMembership;
@ -11,6 +11,8 @@
uses(RefreshDatabase::class);
it('bootstraps tenant creator as owner and audits the assignment', function () {
config(['tenantpilot.onboarding.credentials_required' => 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();