TenantAtlas/app/Filament/Pages/TenantOnboardingWizard.php
2026-02-01 12:20:09 +01:00

821 lines
25 KiB
PHP

<?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();
}
}