Compare commits
1 Commits
dev
...
069-manage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31376c422e |
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||
|
||||
@ -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
|
||||
|
||||
820
app/Filament/Pages/TenantOnboardingWizard.php
Normal file
820
app/Filament/Pages/TenantOnboardingWizard.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
122
app/Jobs/TenantOnboardingVerifyJob.php
Normal file
122
app/Jobs/TenantOnboardingVerifyJob.php
Normal 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.',
|
||||
]],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
40
app/Models/TenantOnboardingSession.php
Normal file
40
app/Models/TenantOnboardingSession.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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')
|
||||
|
||||
68
app/Services/TenantOnboardingAuditService.php
Normal file
68
app/Services/TenantOnboardingAuditService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
142
app/Services/TenantOnboardingSessionService.php
Normal file
142
app/Services/TenantOnboardingSessionService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
9
app/Support/Audit/AuditActions.php
Normal file
9
app/Support/Audit/AuditActions.php
Normal 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';
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
36
database/factories/TenantOnboardingSessionFactory.php
Normal file
36
database/factories/TenantOnboardingSessionFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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 tenant’s “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>
|
||||
@ -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');
|
||||
|
||||
@ -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.
|
||||
@ -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.
|
||||
|
||||
87
specs/069-managed-tenant-onboarding-wizard/data-model.md
Normal file
87
specs/069-managed-tenant-onboarding-wizard/data-model.md
Normal 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 hasn’t provided a valid tenant GUID yet
|
||||
- `created_by_user_id` FK → `users.id`
|
||||
- `status` string: `active|completed|abandoned`
|
||||
- `current_step` string: `welcome|tenant_details|credentials|permissions|verification`
|
||||
- `payload` jsonb
|
||||
- contains non-secret form state only (e.g., name, tenant_id, domain, environment)
|
||||
- MUST NOT contain secrets
|
||||
- `last_error_code` nullable string
|
||||
- `last_error_message` nullable string (sanitized; no tokens/secrets)
|
||||
- `completed_at` nullable datetime
|
||||
- `abandoned_at` nullable datetime
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### Indexes and constraints
|
||||
- Ensure at most one active session per tenant:
|
||||
- PostgreSQL partial unique index: `(tenant_id)` where `status = 'active'`
|
||||
- Dedupe/resume lookup:
|
||||
- index `(created_by_user_id, status)`
|
||||
- index `(tenant_id, status)`
|
||||
|
||||
### State transitions
|
||||
- `active` → `completed` when:
|
||||
- tenant record exists
|
||||
- credentials requirement (if enabled) is satisfied
|
||||
- last verification run indicates success
|
||||
- `active` → `abandoned` when user explicitly cancels
|
||||
|
||||
## Entity: OperationRun (`App\\Models\\OperationRun`)
|
||||
|
||||
Wizard-triggered checks must be observable via `OperationRun`.
|
||||
|
||||
### Relevant fields
|
||||
- `tenant_id` FK
|
||||
- `type` string (examples already in repo: `provider.connection.check`, `inventory.sync`, `compliance.snapshot`)
|
||||
- `status` / `outcome`
|
||||
- `run_identity_hash` (dedupe identity)
|
||||
- `context` (json)
|
||||
|
||||
### Idempotency
|
||||
Use `OperationRunService::ensureRun()` / `ensureRunWithIdentity()` to get DB-level active-run dedupe.
|
||||
|
||||
## Capability / Authorization model
|
||||
- Capabilities are strings from the canonical registry `App\\Support\\Auth\\Capabilities`.
|
||||
- Capability checks:
|
||||
- Membership: `CapabilityResolver::isMember()`
|
||||
- Capability: `CapabilityResolver::can()`
|
||||
- Tenant-scoped non-member access is denied-as-not-found (404) by `DenyNonMemberTenantAccess` middleware.
|
||||
- Filament actions use `App\\Support\\Rbac\\UiEnforcement` to apply:
|
||||
- hidden UI for non-members
|
||||
- disabled UI + tooltip for members lacking the capability
|
||||
- server-side 404/403 guardrails
|
||||
127
specs/069-managed-tenant-onboarding-wizard/plan.md
Normal file
127
specs/069-managed-tenant-onboarding-wizard/plan.md
Normal 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] |
|
||||
24
specs/069-managed-tenant-onboarding-wizard/quickstart.md
Normal file
24
specs/069-managed-tenant-onboarding-wizard/quickstart.md
Normal 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`
|
||||
|
||||
63
specs/069-managed-tenant-onboarding-wizard/research.md
Normal file
63
specs/069-managed-tenant-onboarding-wizard/research.md
Normal 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.
|
||||
157
specs/069-managed-tenant-onboarding-wizard/spec.md
Normal file
157
specs/069-managed-tenant-onboarding-wizard/spec.md
Normal 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 spec’s term **Workspace** maps to the existing **Tenant** concept (tenant-plane container + memberships).
|
||||
- Capability names shown in this spec (e.g. `managed_tenants.create`) are **conceptual** for stakeholders; implementation MUST map them onto the canonical capability registry and MUST NOT introduce new raw capability strings in feature code.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
|
||||
### User Story 1 - Onboard a managed tenant end-to-end (Priority: P1)
|
||||
|
||||
As a workspace Owner, I can onboard a new Managed Tenant through a consistent, guided wizard so onboarding is repeatable and results in a tenant that is ready to run verification/health operations.
|
||||
|
||||
**Why this priority**: This is the primary business outcome: reliable onboarding and operational readiness.
|
||||
|
||||
**Independent Test**: Can be fully tested by completing the wizard and observing that the system marks onboarding complete and allows runs to be started.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is a workspace Owner and no Managed Tenant exists for the target tenant ID, **When** they start the wizard and complete the steps, **Then** a Managed Tenant record exists and onboarding is marked complete.
|
||||
2. **Given** a user started onboarding and leaves mid-way, **When** they return, **Then** they can resume the wizard at the last completed step with their previously entered (non-secret) data.
|
||||
3. **Given** a Managed Tenant already exists in the workspace with the same tenant ID, **When** the user enters that tenant ID, **Then** the wizard prevents creating a duplicate and guides the user to the existing tenant's onboarding/resume state.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Run verification checks without blocking page loads (Priority: P2)
|
||||
|
||||
As an authorized operator, I can trigger verification/health operations for a Managed Tenant so the system checks permissions and connectivity without performing external calls during page rendering.
|
||||
|
||||
**Why this priority**: Operational safety and predictability; the UI must remain responsive and all outbound work must be observable.
|
||||
|
||||
**Independent Test**: Can be tested by loading wizard steps (no outbound activity on render) and then triggering a verification action that creates a run.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a Managed Tenant is in onboarding, **When** the user clicks “Verify permissions”, **Then** a background run is queued and the page does not perform synchronous external calls.
|
||||
2. **Given** the last verification run reported missing permissions, **When** the user visits the permissions step, **Then** they see the stored “Granted/Missing” status from the last run.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - RBAC-UX enforcement and safe access semantics (Priority: P3)
|
||||
|
||||
As a tenant-plane user, I can only see and interact with wizard and tenant actions I am entitled to, with deny-as-not-found for non-members and server-side enforcement for every action.
|
||||
|
||||
**Why this priority**: Prevents information leakage across tenants/workspaces and ensures policy-compliant enforcement.
|
||||
|
||||
**Independent Test**: Can be tested by attempting to access the wizard as a non-member, and as a member lacking specific capabilities.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is not a member of the workspace scope, **When** they attempt to access the onboarding wizard or tenant pages, **Then** they receive a 404 response (deny-as-not-found).
|
||||
2. **Given** a user is a member but lacks the relevant capability, **When** they view the wizard step, **Then** restricted actions are disabled with an explanatory tooltip and server-side attempts are rejected with 403.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Invalid tenant ID format entered (not a UUID/GUID).
|
||||
- Attempt to create a second Managed Tenant with the same tenant ID within the same workspace.
|
||||
- Two users start onboarding the same Managed Tenant concurrently.
|
||||
- A user loses membership/capabilities while an onboarding session is in progress.
|
||||
- Verification run fails (transient error) and surfaces a stored error code/status without breaking page rendering.
|
||||
- Credentials are required but not yet set; wizard shows “missing” state.
|
||||
- Credentials were set previously; wizard shows “set” state without revealing secret values.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (`OperationRun` type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip `OperationRun`, the spec MUST describe `AuditLog` entries.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403 (Forbidden)
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require confirmation (`->requiresConfirmation()`),
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an `OperationRun`. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
### Assumptions & Dependencies
|
||||
|
||||
- Depends on the existing workspace + managed tenant foundations from Spec 068 v2 (including canonical naming and tenant-plane routing).
|
||||
- The onboarding wizard lives in the tenant-plane admin area (not the platform/system area).
|
||||
- Credential capture is required only if the product uses local credentials for managed tenants; otherwise that step is skipped/hidden.
|
||||
- A single configuration/driver flag determines whether credentials are required for the current environment.
|
||||
- Permission/connection status displayed in the wizard is based on stored results from the latest completed verification run.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Single Front Door)**: The system MUST allow creation of a new Managed Tenant only via the onboarding wizard.
|
||||
- **FR-002 (Disable Legacy Entry Points)**: The system MUST remove or disable all previous “Add Tenant/Create” entry points and MUST redirect any legacy creation URLs to an onboarding-appropriate destination.
|
||||
- **FR-002a (Legacy /admin/new Redirect)**: Requests to `/admin/new` MUST NOT create a managed tenant and MUST redirect to the “Choose workspace” entry point.
|
||||
- **FR-003 (DB-only Render)**: Loading any wizard step MUST NOT trigger outbound HTTP calls; step pages MUST render exclusively from persisted data (including latest known run results).
|
||||
- **FR-004 (Wizard Steps)**: The wizard MUST provide 5 steps: (1) Welcome/Requirements, (2) Tenant Details, (3) App/Credentials Setup (when applicable), (4) Admin Consent & Permissions, (5) Verification / First Run.
|
||||
- **FR-005 (Tenant Details Validation)**: The wizard MUST require a tenant ID (UUID/GUID) and validate its format.
|
||||
- **FR-005a (Tenant Details Fields)**: The tenant details step MUST capture: display name, tenant ID (required), optional domain, and an environment label (dev/staging/prod/other).
|
||||
- **FR-006 (Uniqueness)**: The system MUST prevent duplicates by enforcing uniqueness of Managed Tenant by `(workspace, tenant ID)`.
|
||||
- **FR-007 (Onboarding State)**: The system MUST track onboarding state per Managed Tenant and set initial state to “onboarding” when created/updated via the wizard.
|
||||
- **FR-008 (Credentials - Optional Step)**: If the product requires local credentials for managed tenants, the wizard MUST support setting them as part of onboarding. If not required, the wizard MUST skip this step.
|
||||
- **FR-008b (Credentials Decision Rule)**: The wizard MUST decide whether to include the credentials step based on a single configuration/driver rule (no ad-hoc per-page checks).
|
||||
- **FR-008a (Credential Fields)**: When the credentials step is applicable, it MUST allow setting a client identifier and a client secret, and MAY allow optional labeling/notes without exposing secret values.
|
||||
- **FR-009 (Credentials Security)**: When credentials are used, the system MUST store secrets encrypted at rest and MUST never display secret values after they are saved; the UI MUST only show “secret set” vs “missing”.
|
||||
- **FR-010 (Credentials RBAC)**: Only users with “manage” capability for managed tenants MUST be allowed to set/rotate credentials.
|
||||
- **FR-011 (Runs Canonical / Enqueue-only)**: “Verify permissions”, “Check connection”, and optional “Run inventory sync” MUST enqueue background runs and MUST NOT perform external calls synchronously.
|
||||
- **FR-012 (Admin Consent & Permissions UX)**: The permissions step MUST show a required permissions list, MUST display “Granted/Missing” derived from the latest completed verification run, and MUST provide a link for administrators to grant consent.
|
||||
- **FR-013 (Resume / Session Persistence)**: The system MUST persist onboarding sessions and allow users to resume an in-progress onboarding flow; persisted session payload MUST exclude secrets.
|
||||
- **FR-014 (Session Dedupe)**: The system MUST ensure at most one active onboarding session exists per Managed Tenant and deduplicate accordingly.
|
||||
- **FR-014a (Session Dedupe Behavior)**: When a user attempts to start onboarding for a tenant with an existing active session, the system MUST reuse that session and route the user to resume it.
|
||||
- **FR-015 (Completion Criteria)**: The wizard MUST mark onboarding “complete” when the Managed Tenant exists, required credentials (if applicable) are present, and the permissions verification is successful.
|
||||
- **FR-016 (Resume Link)**: The Managed Tenant view MUST show a “Resume wizard” entry point when onboarding is not complete.
|
||||
- **FR-016a (Resume Authorization)**: Resuming an onboarding session MUST be allowed for any workspace member who has `managed_tenants.create` within that workspace scope.
|
||||
- **FR-017 (Capabilities v1)**: The system MUST support these minimum capabilities: managed_tenants.create (start wizard), managed_tenants.manage (credentials/edit), managed_tenants.view, operations.run (start verify/health/inventory runs).
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Workspace**: Customer/organization container; owns Managed Tenants; defines membership scope.
|
||||
- **Managed Tenant**: A Microsoft/Entra/Intune tenant managed within a workspace; identified by a tenant ID; includes onboarding state and metadata (display name, optional domain, environment label).
|
||||
- **Onboarding Session**: A resumable onboarding state container with: workspace, optional managed tenant reference, creator, status (draft/in progress/completed/abandoned), current step, non-secret payload, last error code, timestamps.
|
||||
- **Operation Run**: An observable, queued execution record for verification/health/sync actions initiated from the wizard.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Workspace Owners can complete onboarding for a new Managed Tenant in under 10 minutes (excluding time waiting for admin consent).
|
||||
- **SC-002**: 100% of wizard step page loads complete without initiating outbound HTTP calls (outbound activity occurs only when a user triggers a run action).
|
||||
- **SC-003**: Users can resume an in-progress wizard in 2 clicks or fewer from the Managed Tenant view.
|
||||
- **SC-004**: After onboarding completion, authorized users can start verification/health runs successfully for the tenant.
|
||||
- **SC-005**: Non-members receive deny-as-not-found behavior (404) for tenant-plane onboarding/managed tenant pages; members lacking capabilities are prevented from performing restricted actions.
|
||||
213
specs/069-managed-tenant-onboarding-wizard/tasks.md
Normal file
213
specs/069-managed-tenant-onboarding-wizard/tasks.md
Normal 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: T003–T004 can run in parallel.
|
||||
- Foundational: T007 can run in parallel with T005–T006.
|
||||
- US1 tests (T013–T015) can be authored in parallel.
|
||||
- US2 tests (T030–T032) can be authored in parallel.
|
||||
- US3 tests (T040–T042) can be authored in parallel.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Tests in parallel
|
||||
T013 # tests/Feature/ManagedTenantOnboardingWizardTest.php
|
||||
T014 # tests/Feature/ManagedTenantOnboardingWizardResumeTest.php
|
||||
T015 # tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php
|
||||
|
||||
# UI + service split
|
||||
T016 # app/Services/TenantOnboardingSessionService.php
|
||||
T017 # app/Filament/Pages/TenantOnboardingWizard.php
|
||||
T018 # resources/views/filament/pages/tenant-onboarding-wizard.blade.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Tests in parallel
|
||||
T030 # tests/Feature/ManagedTenantOnboardingWizardDbOnlyRenderTest.php
|
||||
T031 # tests/Feature/TenantOnboardingVerifyOperationRunTest.php
|
||||
T032 # tests/Feature/ManagedTenantOnboardingWizardPermissionsViewTest.php
|
||||
|
||||
# Job + UI work split
|
||||
T036 # app/Jobs/TenantOnboardingVerifyJob.php
|
||||
T035 # app/Filament/Pages/TenantOnboardingWizard.php
|
||||
T038 # resources/views/filament/pages/tenant-onboarding-wizard.blade.php
|
||||
```
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Tests in parallel
|
||||
T040 # tests/Feature/ManagedTenantOnboardingWizardRbacTest.php
|
||||
T042 # tests/Feature/ManagedTenantOnboardingWizardUiEnforcementTest.php
|
||||
|
||||
# Enforcement
|
||||
T043 # app/Filament/Pages/TenantOnboardingWizard.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP scope
|
||||
|
||||
- MVP = US1 only (wizard + session persistence + single front door).
|
||||
|
||||
### Incremental delivery
|
||||
|
||||
1. Setup + Foundational.
|
||||
2. Deliver US1 (MVP) and validate independently.
|
||||
3. Add US2 (enqueue-only verification) and validate independently.
|
||||
4. Add US3 (RBAC-UX hardening + regression tests).
|
||||
8
tests/Feature/AdminNewRedirectTest.php
Normal file
8
tests/Feature/AdminNewRedirectTest.php
Normal 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');
|
||||
});
|
||||
@ -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',
|
||||
]);
|
||||
|
||||
@ -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()])
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
42
tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php
Normal file
42
tests/Feature/ManagedTenantOnboardingWizardDuplicateTest.php
Normal 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);
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
52
tests/Feature/ManagedTenantOnboardingWizardRbacTest.php
Normal file
52
tests/Feature/ManagedTenantOnboardingWizardRbacTest.php
Normal 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);
|
||||
});
|
||||
51
tests/Feature/ManagedTenantOnboardingWizardResumeTest.php
Normal file
51
tests/Feature/ManagedTenantOnboardingWizardResumeTest.php
Normal 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);
|
||||
});
|
||||
55
tests/Feature/ManagedTenantOnboardingWizardTest.php
Normal file
55
tests/Feature/ManagedTenantOnboardingWizardTest.php
Normal 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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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:');
|
||||
});
|
||||
@ -18,6 +18,6 @@
|
||||
$this->actingAs($user);
|
||||
$tenant->makeCurrent();
|
||||
|
||||
expect(RegisterTenant::canView())->toBeTrue();
|
||||
expect(RegisterTenant::canView())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
45
tests/Feature/TenantOnboardingAuditServiceTest.php
Normal file
45
tests/Feature/TenantOnboardingAuditServiceTest.php
Normal 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');
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
32
tests/Feature/TenantOnboardingSessionTest.php
Normal file
32
tests/Feature/TenantOnboardingSessionTest.php
Normal 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);
|
||||
});
|
||||
45
tests/Feature/TenantOnboardingVerifyOperationRunTest.php
Normal file
45
tests/Feature/TenantOnboardingVerifyOperationRunTest.php
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user