feat: add onboarding readiness workflow (#277)
Some checks failed
Main Confidence / confidence (push) Failing after 48s
Some checks failed
Main Confidence / confidence (push) Failing after 48s
## Summary - add derived onboarding readiness to the managed tenant onboarding workflow and multi-draft picker - keep provider-specific permission diagnostics secondary while preserving canonical `Open operation` and existing onboarding action semantics - add spec-kit artifacts for `240-tenant-onboarding-readiness` and align roadmap/spec-candidate planning notes - unify the required-permissions empty state copy to English ## Validation - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/RequiredPermissions/RequiredPermissionsEmptyStateTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - browser smoke exercised the onboarding picker, route-bound mismatch readiness state, canonical `Open operation` path, and local fixture cleanup ## Notes - branch includes the generated spec artifacts under `specs/240-tenant-onboarding-readiness/` - temporary browser smoke tenants/drafts/runs were cleaned from the local environment after validation Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #277
This commit is contained in:
parent
fb32e9bfa5
commit
ab6eccaf40
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -256,6 +256,8 @@ ## Active Technologies
|
|||||||
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
- Existing PostgreSQL tables such as `provider_connections`, `provider_credentials`, and existing audit tables; no new database tables planned (238-provider-identity-target-scope)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4 (239-canonical-operation-type-source-of-truth)
|
||||||
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
- PostgreSQL via existing `operation_runs.type` and `managed_tenant_onboarding_sessions.state->bootstrap_operation_types`, plus config-backed `tenantpilot.operations.lifecycle.covered_types` and `tenantpilot.platform_vocabulary`; no new tables (239-canonical-operation-type-source-of-truth)
|
||||||
|
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers (240-tenant-onboarding-readiness)
|
||||||
|
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -290,9 +292,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||||
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||||
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
- 238-provider-identity-target-scope: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `ProviderConnection`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `ProviderConnectionMutationService`, `ProviderConnectionStateProjector`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `BadgeRenderer`, Pest v4
|
||||||
- 237-provider-boundary-hardening: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing provider seams under `App\Services\Providers` and `App\Services\Graph`, especially `ProviderGateway`, `ProviderIdentityResolver`, `ProviderIdentityResolution`, `PlatformProviderIdentityResolver`, `ProviderConnectionResolver`, `ProviderConnectionResolution`, `MicrosoftGraphOptionsResolver`, `ProviderOperationRegistry`, `ProviderOperationStartGate`, `GraphClientInterface`, Pest v4
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
@ -24,6 +24,7 @@
|
|||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder;
|
||||||
use App\Services\Onboarding\OnboardingDraftMutationService;
|
use App\Services\Onboarding\OnboardingDraftMutationService;
|
||||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||||
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
||||||
@ -38,6 +39,7 @@
|
|||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\Livewire\TrustedState\TrustedStateResolver;
|
use App\Support\Livewire\TrustedState\TrustedStateResolver;
|
||||||
use App\Support\Onboarding\OnboardingCheckpoint;
|
use App\Support\Onboarding\OnboardingCheckpoint;
|
||||||
use App\Support\Onboarding\OnboardingDraftStage;
|
use App\Support\Onboarding\OnboardingDraftStage;
|
||||||
@ -314,6 +316,7 @@ public function content(Schema $schema): Schema
|
|||||||
SchemaView::make('filament.schemas.components.managed-tenant-onboarding-checkpoint-poll')
|
SchemaView::make('filament.schemas.components.managed-tenant-onboarding-checkpoint-poll')
|
||||||
->visible(fn (): bool => $this->shouldPollCheckpointLifecycle()),
|
->visible(fn (): bool => $this->shouldPollCheckpointLifecycle()),
|
||||||
...$this->resumeContextSchema(),
|
...$this->resumeContextSchema(),
|
||||||
|
...$this->routeBoundReadinessSchema(),
|
||||||
Wizard::make([
|
Wizard::make([
|
||||||
Step::make('Identify managed tenant')
|
Step::make('Identify managed tenant')
|
||||||
->description('Create or resume a managed tenant in this workspace.')
|
->description('Create or resume a managed tenant in this workspace.')
|
||||||
@ -926,6 +929,7 @@ private function draftPickerSchema(): array
|
|||||||
Text::make('Draft age')
|
Text::make('Draft age')
|
||||||
->color('gray'),
|
->color('gray'),
|
||||||
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
Text::make(fn (): string => $draft->created_at?->diffForHumans() ?? '—'),
|
||||||
|
...$this->draftCompactReadinessSchema($draft),
|
||||||
SchemaActions::make([
|
SchemaActions::make([
|
||||||
Action::make('resume_draft_'.$draft->getKey())
|
Action::make('resume_draft_'.$draft->getKey())
|
||||||
->label($this->resumeOnboardingActionLabel())
|
->label($this->resumeOnboardingActionLabel())
|
||||||
@ -978,6 +982,840 @@ private function resumeContextSchema(): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function routeBoundReadinessSchema(): array
|
||||||
|
{
|
||||||
|
$draft = $this->currentOnboardingSessionRecord();
|
||||||
|
|
||||||
|
if (! $draft instanceof TenantOnboardingSession) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = $this->onboardingReadinessPayload($draft);
|
||||||
|
|
||||||
|
$schema = [
|
||||||
|
Section::make('Onboarding readiness')
|
||||||
|
->description($payload['blocker']['operator_summary'])
|
||||||
|
->compact()
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Text::make('Current checkpoint')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['checkpoint']['current_checkpoint_label'] ?? '—')
|
||||||
|
->badge()
|
||||||
|
->color('info'),
|
||||||
|
Text::make('Lifecycle')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['checkpoint']['lifecycle_label'])
|
||||||
|
->badge()
|
||||||
|
->color($this->readinessLifecycleColor($payload['checkpoint']['lifecycle_state'])),
|
||||||
|
Text::make('Provider connection')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($this->readinessProviderLine($payload))
|
||||||
|
->badge()
|
||||||
|
->color($this->readinessSummaryColor($payload)),
|
||||||
|
Text::make('Freshness')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['freshness']['note']),
|
||||||
|
Text::make('Primary next action')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['next_action']['label'])
|
||||||
|
->badge()
|
||||||
|
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
$permissionDiagnostics = $this->readinessPermissionDiagnosticsSchema($payload, 'route_bound_readiness');
|
||||||
|
$supportingEvidence = $this->readinessSupportingEvidenceSchema($payload, 'route_bound_readiness');
|
||||||
|
|
||||||
|
return array_merge($schema, $permissionDiagnostics, $supportingEvidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function draftCompactReadinessSchema(TenantOnboardingSession $draft): array
|
||||||
|
{
|
||||||
|
$payload = $this->onboardingReadinessPayload($draft);
|
||||||
|
|
||||||
|
return [
|
||||||
|
Text::make('Compact readiness')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['blocker']['operator_summary'])
|
||||||
|
->badge()
|
||||||
|
->color($this->readinessSummaryColor($payload)),
|
||||||
|
Text::make('Freshness')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['freshness']['note']),
|
||||||
|
Text::make('Next action')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make($payload['next_action']['label'])
|
||||||
|
->badge()
|
||||||
|
->color($this->readinessNextActionColor($payload['next_action']['kind'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function readinessSupportingEvidenceSchema(array $payload, string $keyPrefix): array
|
||||||
|
{
|
||||||
|
$links = is_array($payload['supporting_links'] ?? null) ? $payload['supporting_links'] : [];
|
||||||
|
|
||||||
|
if ($links === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions = [];
|
||||||
|
|
||||||
|
foreach (array_values($links) as $index => $link) {
|
||||||
|
if (! is_array($link)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = is_string($link['label'] ?? null) ? $link['label'] : OperationRunLinks::openLabel();
|
||||||
|
$url = is_string($link['url'] ?? null) ? $link['url'] : null;
|
||||||
|
|
||||||
|
if ($url === null || $url === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$actions[] = Action::make($keyPrefix.'_supporting_operation_'.$index)
|
||||||
|
->label($label)
|
||||||
|
->color('gray')
|
||||||
|
->url($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($actions === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Section::make('Supporting evidence')
|
||||||
|
->description('Open canonical operation detail when deeper diagnostics are needed.')
|
||||||
|
->compact()
|
||||||
|
->schema([
|
||||||
|
SchemaActions::make($actions)->key($keyPrefix.'_supporting_evidence_actions'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
|
*/
|
||||||
|
private function readinessPermissionDiagnosticsSchema(array $payload, string $keyPrefix): array
|
||||||
|
{
|
||||||
|
$permissions = is_array($payload['permissions'] ?? null) ? $payload['permissions'] : null;
|
||||||
|
|
||||||
|
if ($permissions === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = is_array($permissions['counts'] ?? null) ? $permissions['counts'] : [];
|
||||||
|
$missingApplication = (int) ($counts['missing_application'] ?? 0);
|
||||||
|
$missingDelegated = (int) ($counts['missing_delegated'] ?? 0);
|
||||||
|
$errors = (int) ($counts['error'] ?? 0);
|
||||||
|
$assist = is_array($payload['verification_assist'] ?? null) ? $payload['verification_assist'] : [];
|
||||||
|
$isVisible = (bool) ($assist['is_visible'] ?? false);
|
||||||
|
|
||||||
|
if ($missingApplication + $missingDelegated + $errors === 0 && ! $isVisible) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = [
|
||||||
|
Text::make('Missing application permissions')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make((string) $missingApplication)
|
||||||
|
->badge()
|
||||||
|
->color($missingApplication > 0 ? 'warning' : 'success'),
|
||||||
|
Text::make('Missing delegated permissions')
|
||||||
|
->color('gray'),
|
||||||
|
Text::make((string) $missingDelegated)
|
||||||
|
->badge()
|
||||||
|
->color($missingDelegated > 0 ? 'warning' : 'success'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$applicationLine = $this->readinessMissingPermissionLine($permissions, 'application');
|
||||||
|
$delegatedLine = $this->readinessMissingPermissionLine($permissions, 'delegated');
|
||||||
|
|
||||||
|
if ($applicationLine !== null) {
|
||||||
|
$schema[] = Text::make('Application permission detail')->color('gray');
|
||||||
|
$schema[] = Text::make($applicationLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($delegatedLine !== null) {
|
||||||
|
$schema[] = Text::make('Delegated permission detail')->color('gray');
|
||||||
|
$schema[] = Text::make($delegatedLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = is_string($permissions['required_permissions_url'] ?? null) ? $permissions['required_permissions_url'] : null;
|
||||||
|
|
||||||
|
if ($url !== null && $url !== '') {
|
||||||
|
$schema[] = SchemaActions::make([
|
||||||
|
Action::make($keyPrefix.'_review_permissions')
|
||||||
|
->label('Review permissions')
|
||||||
|
->color('gray')
|
||||||
|
->url($url),
|
||||||
|
])->key($keyPrefix.'_permission_diagnostics_actions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Section::make('Permission diagnostics')
|
||||||
|
->description('Provider-owned permission detail stays secondary to readiness while still giving exact remediation context.')
|
||||||
|
->compact()
|
||||||
|
->columns(2)
|
||||||
|
->schema($schema),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* draft: array{id: int, tenant_name: string, stage_label: string, draft_status_label: string, started_by: string, updated_by: string, last_updated_human: string},
|
||||||
|
* checkpoint: array{current_checkpoint: string|null, current_checkpoint_label: string|null, last_completed_checkpoint: string|null, lifecycle_state: string, lifecycle_label: string},
|
||||||
|
* provider_summary: array<string, mixed>|null,
|
||||||
|
* verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, matches_selected_connection: bool|null, overall: string|null},
|
||||||
|
* verification_assist: array{is_visible: bool, reason: string},
|
||||||
|
* permissions: array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}|null,
|
||||||
|
* freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string},
|
||||||
|
* blocker: array{reason_code: string|null, blocking_reason_code: string|null, operator_summary: string},
|
||||||
|
* next_action: array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null},
|
||||||
|
* supporting_links: list<array{label: string, url: string}>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function onboardingReadinessPayload(TenantOnboardingSession $draft): array
|
||||||
|
{
|
||||||
|
$snapshot = $this->lifecycleService()->snapshot($draft);
|
||||||
|
$stage = app(OnboardingDraftStageResolver::class)->resolve($draft);
|
||||||
|
$lifecycleState = $snapshot['lifecycle_state'] instanceof OnboardingLifecycleState
|
||||||
|
? $snapshot['lifecycle_state']
|
||||||
|
: OnboardingLifecycleState::Draft;
|
||||||
|
$currentCheckpoint = $snapshot['current_checkpoint'] instanceof OnboardingCheckpoint
|
||||||
|
? $snapshot['current_checkpoint']
|
||||||
|
: null;
|
||||||
|
$lastCompletedCheckpoint = $snapshot['last_completed_checkpoint'] instanceof OnboardingCheckpoint
|
||||||
|
? $snapshot['last_completed_checkpoint']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$tenant = $draft->tenant instanceof Tenant ? $draft->tenant : null;
|
||||||
|
$providerConnection = $this->readinessProviderConnection($draft);
|
||||||
|
$selectedProviderConnectionId = $providerConnection instanceof ProviderConnection
|
||||||
|
? (int) $providerConnection->getKey()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$verificationRun = $this->lifecycleService()->verificationRun($draft);
|
||||||
|
$verificationStatus = $this->lifecycleService()->verificationStatus(
|
||||||
|
draft: $draft,
|
||||||
|
selectedProviderConnectionId: $selectedProviderConnectionId,
|
||||||
|
run: $verificationRun,
|
||||||
|
);
|
||||||
|
$verificationMatchesSelectedConnection = $verificationRun instanceof OperationRun
|
||||||
|
? $this->readinessRunMatchesSelectedConnection($verificationRun, $selectedProviderConnectionId)
|
||||||
|
: null;
|
||||||
|
$permissions = $tenant instanceof Tenant ? $this->readinessPermissionOverview($tenant) : null;
|
||||||
|
$verificationReport = $verificationRun instanceof OperationRun ? VerificationReportViewer::report($verificationRun) : null;
|
||||||
|
$verificationReport = is_array($verificationReport) ? $verificationReport : null;
|
||||||
|
$permissionFreshness = is_array($permissions['freshness'] ?? null) ? $permissions['freshness'] : [
|
||||||
|
'last_refreshed_at' => null,
|
||||||
|
'is_stale' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$connectionRecentlyUpdated = $this->readinessConnectionRecentlyUpdated($draft);
|
||||||
|
$verificationMismatch = $verificationRun instanceof OperationRun && $verificationMatchesSelectedConnection === false;
|
||||||
|
$supportingLinks = $this->readinessSupportingLinks($verificationRun, $draft, $selectedProviderConnectionId);
|
||||||
|
$readinessSummary = $this->readinessSummaryText(
|
||||||
|
draft: $draft,
|
||||||
|
lifecycleState: $lifecycleState,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
verificationRun: $verificationRun,
|
||||||
|
verificationStatus: $verificationStatus,
|
||||||
|
permissions: $permissions,
|
||||||
|
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||||
|
verificationMismatch: $verificationMismatch,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'draft' => [
|
||||||
|
'id' => (int) $draft->getKey(),
|
||||||
|
'tenant_name' => $this->draftTitle($draft),
|
||||||
|
'stage_label' => $stage->label(),
|
||||||
|
'draft_status_label' => $draft->status()->label(),
|
||||||
|
'started_by' => $draft->startedByUser?->name ?? 'Unknown',
|
||||||
|
'updated_by' => $draft->updatedByUser?->name ?? 'Unknown',
|
||||||
|
'last_updated_human' => $draft->updated_at?->diffForHumans() ?? '—',
|
||||||
|
],
|
||||||
|
'checkpoint' => [
|
||||||
|
'current_checkpoint' => $currentCheckpoint?->value,
|
||||||
|
'current_checkpoint_label' => $currentCheckpoint?->label(),
|
||||||
|
'last_completed_checkpoint' => $lastCompletedCheckpoint?->label(),
|
||||||
|
'lifecycle_state' => $lifecycleState->value,
|
||||||
|
'lifecycle_label' => $lifecycleState->label(),
|
||||||
|
],
|
||||||
|
'provider_summary' => $this->readinessProviderSummary($providerConnection),
|
||||||
|
'verification' => [
|
||||||
|
'status' => $verificationStatus,
|
||||||
|
'status_label' => BadgeCatalog::spec(BadgeDomain::ManagedTenantOnboardingVerificationStatus, $verificationStatus)->label,
|
||||||
|
'run_id' => $verificationRun instanceof OperationRun ? (int) $verificationRun->getKey() : null,
|
||||||
|
'run_url' => $verificationRun instanceof OperationRun && $this->canInspectOperationRun($verificationRun)
|
||||||
|
? OperationRunLinks::tenantlessView($verificationRun)
|
||||||
|
: null,
|
||||||
|
'is_active' => $verificationRun instanceof OperationRun && $verificationRun->status !== OperationRunStatus::Completed->value,
|
||||||
|
'matches_selected_connection' => $verificationMatchesSelectedConnection,
|
||||||
|
'overall' => $verificationRun instanceof OperationRun
|
||||||
|
? $this->readinessVerificationOverall($verificationRun, $verificationReport)
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
'verification_assist' => $tenant instanceof Tenant && $verificationReport !== null
|
||||||
|
? app(VerificationAssistViewModelBuilder::class)->visibility($tenant, $verificationReport)
|
||||||
|
: $this->hiddenVerificationAssistVisibility(),
|
||||||
|
'permissions' => $permissions,
|
||||||
|
'freshness' => [
|
||||||
|
'connection_recently_updated' => $connectionRecentlyUpdated,
|
||||||
|
'verification_mismatch' => $verificationMismatch,
|
||||||
|
'permission_last_refreshed_at' => $permissionFreshness['last_refreshed_at'] ?? null,
|
||||||
|
'permission_data_is_stale' => (bool) ($permissionFreshness['is_stale'] ?? true),
|
||||||
|
'note' => $this->readinessFreshnessNote(
|
||||||
|
verificationRun: $verificationRun,
|
||||||
|
verificationStatus: $verificationStatus,
|
||||||
|
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||||
|
verificationMismatch: $verificationMismatch,
|
||||||
|
permissionFreshness: $permissionFreshness,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'blocker' => [
|
||||||
|
'reason_code' => is_string($snapshot['reason_code'] ?? null) ? $snapshot['reason_code'] : null,
|
||||||
|
'blocking_reason_code' => is_string($snapshot['blocking_reason_code'] ?? null) ? $snapshot['blocking_reason_code'] : null,
|
||||||
|
'operator_summary' => $readinessSummary,
|
||||||
|
],
|
||||||
|
'next_action' => $this->readinessNextAction(
|
||||||
|
draft: $draft,
|
||||||
|
lifecycleState: $lifecycleState,
|
||||||
|
providerConnection: $providerConnection,
|
||||||
|
verificationRun: $verificationRun,
|
||||||
|
verificationStatus: $verificationStatus,
|
||||||
|
permissions: $permissions,
|
||||||
|
connectionRecentlyUpdated: $connectionRecentlyUpdated,
|
||||||
|
verificationMismatch: $verificationMismatch,
|
||||||
|
supportingLinks: $supportingLinks,
|
||||||
|
),
|
||||||
|
'supporting_links' => $supportingLinks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function readinessProviderLine(array $payload): string
|
||||||
|
{
|
||||||
|
$summary = is_array($payload['provider_summary'] ?? null) ? $payload['provider_summary'] : null;
|
||||||
|
|
||||||
|
if ($summary === null) {
|
||||||
|
return 'Not connected';
|
||||||
|
}
|
||||||
|
|
||||||
|
$readiness = is_string($summary['readiness_summary'] ?? null) ? $summary['readiness_summary'] : 'Needs review';
|
||||||
|
$targetScope = is_string($summary['target_scope_summary'] ?? null) ? $summary['target_scope_summary'] : null;
|
||||||
|
|
||||||
|
return $targetScope === null || $targetScope === ''
|
||||||
|
? $readiness
|
||||||
|
: sprintf('%s - %s', $readiness, $targetScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function readinessSummaryColor(array $payload): string
|
||||||
|
{
|
||||||
|
$summary = strtolower((string) ($payload['blocker']['operator_summary'] ?? ''));
|
||||||
|
|
||||||
|
if (str_contains($summary, 'ready') || str_contains($summary, 'completed')) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($summary, 'running') || str_contains($summary, 'continue')) {
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($summary, 'required') || str_contains($summary, 'disabled') || str_contains($summary, 'attention') || str_contains($summary, 'refresh')) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessLifecycleColor(string $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
OnboardingLifecycleState::ReadyForActivation->value,
|
||||||
|
OnboardingLifecycleState::Completed->value => 'success',
|
||||||
|
OnboardingLifecycleState::Verifying->value,
|
||||||
|
OnboardingLifecycleState::Bootstrapping->value => 'info',
|
||||||
|
OnboardingLifecycleState::ActionRequired->value => 'warning',
|
||||||
|
OnboardingLifecycleState::Cancelled->value => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessNextActionColor(string $kind): string
|
||||||
|
{
|
||||||
|
return match ($kind) {
|
||||||
|
'complete_onboarding' => 'success',
|
||||||
|
'grant_consent',
|
||||||
|
'review_provider_connection',
|
||||||
|
'review_permissions',
|
||||||
|
'rerun_verification',
|
||||||
|
'review_bootstrap' => 'warning',
|
||||||
|
'start_verification',
|
||||||
|
'open_operation' => 'info',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessProviderConnection(TenantOnboardingSession $draft): ?ProviderConnection
|
||||||
|
{
|
||||||
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
|
$providerConnectionId = $this->normalizeReadinessInteger(
|
||||||
|
$state['provider_connection_id'] ?? $state['selected_provider_connection_id'] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($providerConnectionId === null || $draft->tenant_id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProviderConnection::query()
|
||||||
|
->whereKey($providerConnectionId)
|
||||||
|
->where('workspace_id', (int) $draft->workspace_id)
|
||||||
|
->where('tenant_id', (int) $draft->tenant_id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function readinessProviderSummary(?ProviderConnection $connection): ?array
|
||||||
|
{
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$summary = ProviderConnectionSurfaceSummary::forConnection($connection);
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
return [
|
||||||
|
'provider' => (string) $connection->provider,
|
||||||
|
'target_scope' => [],
|
||||||
|
'consent_state' => (string) $connection->consent_status,
|
||||||
|
'verification_state' => (string) $connection->verification_status,
|
||||||
|
'readiness_summary' => 'Target scope needs review',
|
||||||
|
'target_scope_summary' => 'Target scope needs review',
|
||||||
|
'contextual_identity_line' => null,
|
||||||
|
'is_enabled' => (bool) $connection->is_enabled,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($summary->toArray(), [
|
||||||
|
'target_scope_summary' => $summary->targetScopeSummary(),
|
||||||
|
'contextual_identity_line' => $summary->contextualIdentityLine(),
|
||||||
|
'is_enabled' => (bool) $connection->is_enabled,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{overall: string|null, counts: array<string, int>, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list<string>, delegated: list<string>}, required_permissions_url: string|null}
|
||||||
|
*/
|
||||||
|
private function readinessPermissionOverview(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$viewModel = app(TenantRequiredPermissionsViewModelBuilder::class)->build($tenant, [
|
||||||
|
'status' => 'all',
|
||||||
|
'type' => 'all',
|
||||||
|
'features' => [],
|
||||||
|
'search' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$overview = is_array($viewModel['overview'] ?? null) ? $viewModel['overview'] : [];
|
||||||
|
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
|
||||||
|
$freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : [];
|
||||||
|
$permissions = is_array($viewModel['permissions'] ?? null) ? $viewModel['permissions'] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'overall' => is_string($overview['overall'] ?? null) ? $overview['overall'] : null,
|
||||||
|
'counts' => [
|
||||||
|
'missing_application' => max(0, (int) ($counts['missing_application'] ?? 0)),
|
||||||
|
'missing_delegated' => max(0, (int) ($counts['missing_delegated'] ?? 0)),
|
||||||
|
'present' => max(0, (int) ($counts['present'] ?? 0)),
|
||||||
|
'error' => max(0, (int) ($counts['error'] ?? 0)),
|
||||||
|
],
|
||||||
|
'freshness' => [
|
||||||
|
'last_refreshed_at' => is_string($freshness['last_refreshed_at'] ?? null) ? $freshness['last_refreshed_at'] : null,
|
||||||
|
'is_stale' => (bool) ($freshness['is_stale'] ?? true),
|
||||||
|
],
|
||||||
|
'missing_permissions' => [
|
||||||
|
'application' => $this->readinessMissingPermissionKeys($permissions, 'application'),
|
||||||
|
'delegated' => $this->readinessMissingPermissionKeys($permissions, 'delegated'),
|
||||||
|
],
|
||||||
|
'required_permissions_url' => RequiredPermissionsLinks::requiredPermissions($tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $permissions
|
||||||
|
* @param 'application'|'delegated' $type
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function readinessMissingPermissionKeys(array $permissions, string $type): array
|
||||||
|
{
|
||||||
|
return collect($permissions)
|
||||||
|
->filter(static fn (mixed $row): bool => is_array($row)
|
||||||
|
&& ($row['status'] ?? null) === 'missing'
|
||||||
|
&& ($row['type'] ?? null) === $type
|
||||||
|
&& is_string($row['key'] ?? null)
|
||||||
|
&& trim((string) $row['key']) !== '')
|
||||||
|
->map(static fn (array $row): string => trim((string) $row['key']))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $permissions
|
||||||
|
* @param 'application'|'delegated' $type
|
||||||
|
*/
|
||||||
|
private function readinessMissingPermissionLine(array $permissions, string $type): ?string
|
||||||
|
{
|
||||||
|
$missing = is_array($permissions['missing_permissions'][$type] ?? null)
|
||||||
|
? $permissions['missing_permissions'][$type]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$missing = array_values(array_filter($missing, static fn (mixed $value): bool => is_string($value) && trim($value) !== ''));
|
||||||
|
|
||||||
|
if ($missing === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', array_slice($missing, 0, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{last_refreshed_at?: string|null, is_stale?: bool} $permissionFreshness
|
||||||
|
*/
|
||||||
|
private function readinessFreshnessNote(
|
||||||
|
?OperationRun $verificationRun,
|
||||||
|
string $verificationStatus,
|
||||||
|
bool $connectionRecentlyUpdated,
|
||||||
|
bool $verificationMismatch,
|
||||||
|
array $permissionFreshness,
|
||||||
|
): string {
|
||||||
|
if (! $verificationRun instanceof OperationRun) {
|
||||||
|
return 'Verification has not run yet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connectionRecentlyUpdated) {
|
||||||
|
return 'Provider connection changed; rerun verification to refresh readiness.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationMismatch) {
|
||||||
|
return 'Verification evidence belongs to a different provider connection.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) ($permissionFreshness['is_stale'] ?? true)) {
|
||||||
|
return is_string($permissionFreshness['last_refreshed_at'] ?? null)
|
||||||
|
? 'Permission data is older than the 30-day freshness window.'
|
||||||
|
: 'Permission check has not run yet.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationStatus === 'in_progress') {
|
||||||
|
return 'Verification is running; refresh for the latest result.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Verification and permission evidence are current.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $permissions
|
||||||
|
*/
|
||||||
|
private function readinessSummaryText(
|
||||||
|
TenantOnboardingSession $draft,
|
||||||
|
OnboardingLifecycleState $lifecycleState,
|
||||||
|
?ProviderConnection $providerConnection,
|
||||||
|
?OperationRun $verificationRun,
|
||||||
|
string $verificationStatus,
|
||||||
|
?array $permissions,
|
||||||
|
bool $connectionRecentlyUpdated,
|
||||||
|
bool $verificationMismatch,
|
||||||
|
): string {
|
||||||
|
if (! $this->readinessDraftHasTenantIdentity($draft)) {
|
||||||
|
return 'Tenant identity required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $providerConnection instanceof ProviderConnection) {
|
||||||
|
return 'Provider connection required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) $providerConnection->is_enabled) {
|
||||||
|
return 'Provider connection disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
$consentState = $providerConnection->consent_status instanceof ProviderConsentStatus
|
||||||
|
? $providerConnection->consent_status->value
|
||||||
|
: (string) $providerConnection->consent_status;
|
||||||
|
|
||||||
|
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||||
|
return match ($consentState) {
|
||||||
|
ProviderConsentStatus::Revoked->value => 'Provider consent revoked',
|
||||||
|
ProviderConsentStatus::Failed->value => 'Provider consent failed',
|
||||||
|
default => 'Provider consent required',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connectionRecentlyUpdated || $verificationMismatch) {
|
||||||
|
return 'Verification needs refresh';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $verificationRun instanceof OperationRun) {
|
||||||
|
return 'Verification has not run yet';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationStatus === 'in_progress') {
|
||||||
|
return 'Verification running';
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
||||||
|
|
||||||
|
if ($verificationStatus === 'blocked' || $permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||||
|
return 'Permission or consent blocker needs attention';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($permissionOverall === VerificationReportOverall::NeedsAttention->value || (bool) ($permissions['freshness']['is_stale'] ?? false)) {
|
||||||
|
return 'Readiness needs attention';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($lifecycleState) {
|
||||||
|
OnboardingLifecycleState::Verifying => 'Verification running',
|
||||||
|
OnboardingLifecycleState::Bootstrapping => 'Bootstrap running',
|
||||||
|
OnboardingLifecycleState::ActionRequired => 'Onboarding needs attention',
|
||||||
|
OnboardingLifecycleState::ReadyForActivation => 'Ready for activation',
|
||||||
|
OnboardingLifecycleState::Completed => 'Completed',
|
||||||
|
OnboardingLifecycleState::Cancelled => 'Cancelled',
|
||||||
|
default => 'Continue onboarding',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $permissions
|
||||||
|
* @param list<array{label: string, url: string}> $supportingLinks
|
||||||
|
* @return array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null}
|
||||||
|
*/
|
||||||
|
private function readinessNextAction(
|
||||||
|
TenantOnboardingSession $draft,
|
||||||
|
OnboardingLifecycleState $lifecycleState,
|
||||||
|
?ProviderConnection $providerConnection,
|
||||||
|
?OperationRun $verificationRun,
|
||||||
|
string $verificationStatus,
|
||||||
|
?array $permissions,
|
||||||
|
bool $connectionRecentlyUpdated,
|
||||||
|
bool $verificationMismatch,
|
||||||
|
array $supportingLinks,
|
||||||
|
): array {
|
||||||
|
if (! $this->readinessDraftHasTenantIdentity($draft)) {
|
||||||
|
return $this->readinessAction('Identify tenant', 'start_onboarding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $providerConnection instanceof ProviderConnection) {
|
||||||
|
return $this->readinessAction('Connect provider', 'resume_draft');
|
||||||
|
}
|
||||||
|
|
||||||
|
$consentState = $providerConnection->consent_status instanceof ProviderConsentStatus
|
||||||
|
? $providerConnection->consent_status->value
|
||||||
|
: (string) $providerConnection->consent_status;
|
||||||
|
|
||||||
|
if (! (bool) $providerConnection->is_enabled) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: 'Review provider connection',
|
||||||
|
kind: 'review_provider_connection',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($consentState !== ProviderConsentStatus::Granted->value) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: 'Grant consent',
|
||||||
|
kind: 'grant_consent',
|
||||||
|
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::adminConsentPrimaryUrl($draft->tenant) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null;
|
||||||
|
|
||||||
|
if ($permissionOverall === VerificationReportOverall::Blocked->value) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: 'Review permissions',
|
||||||
|
kind: 'review_permissions',
|
||||||
|
url: $draft->tenant instanceof Tenant ? RequiredPermissionsLinks::requiredPermissions($draft->tenant) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $verificationRun instanceof OperationRun) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: 'Start verification',
|
||||||
|
kind: 'start_verification',
|
||||||
|
actionName: 'wizardStartVerification',
|
||||||
|
requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($connectionRecentlyUpdated || $verificationMismatch || (bool) ($permissions['freshness']['is_stale'] ?? false)) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: 'Rerun verification',
|
||||||
|
kind: 'rerun_verification',
|
||||||
|
actionName: 'wizardStartVerification',
|
||||||
|
requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_VERIFICATION_START,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($verificationStatus === 'in_progress' && $supportingLinks !== []) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: OperationRunLinks::openLabel(),
|
||||||
|
kind: 'open_operation',
|
||||||
|
url: $supportingLinks[0]['url'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lifecycleState === OnboardingLifecycleState::Bootstrapping || $lifecycleState === OnboardingLifecycleState::ActionRequired) {
|
||||||
|
return $this->readinessAction('Review bootstrap', 'review_bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($lifecycleState === OnboardingLifecycleState::ReadyForActivation) {
|
||||||
|
return $this->readinessAction(
|
||||||
|
label: 'Complete onboarding',
|
||||||
|
kind: 'complete_onboarding',
|
||||||
|
actionName: 'wizardCompleteOnboarding',
|
||||||
|
requiredCapability: Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->readinessAction('Continue onboarding', 'resume_draft');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null}
|
||||||
|
*/
|
||||||
|
private function readinessAction(
|
||||||
|
string $label,
|
||||||
|
string $kind,
|
||||||
|
?string $url = null,
|
||||||
|
?string $actionName = null,
|
||||||
|
?string $requiredCapability = null,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'label' => $label,
|
||||||
|
'kind' => $kind,
|
||||||
|
'url' => $url,
|
||||||
|
'action_name' => $actionName,
|
||||||
|
'required_capability' => $requiredCapability,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{label: string, url: string}>
|
||||||
|
*/
|
||||||
|
private function readinessSupportingLinks(?OperationRun $verificationRun, TenantOnboardingSession $draft, ?int $selectedProviderConnectionId): array
|
||||||
|
{
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
if ($verificationRun instanceof OperationRun && $this->canInspectOperationRun($verificationRun)) {
|
||||||
|
$links[] = [
|
||||||
|
'label' => OperationRunLinks::openLabel(),
|
||||||
|
'url' => OperationRunLinks::tenantlessView($verificationRun),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->lifecycleService()->bootstrapRunSummaries($draft, $selectedProviderConnectionId) as $summary) {
|
||||||
|
$runId = $this->normalizeReadinessInteger($summary['run_id'] ?? null);
|
||||||
|
|
||||||
|
if ($runId === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->whereKey($runId)
|
||||||
|
->where('workspace_id', (int) $draft->workspace_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun || ! $this->canInspectOperationRun($run)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$links[] = [
|
||||||
|
'label' => OperationRunLinks::openLabel(),
|
||||||
|
'url' => OperationRunLinks::tenantlessView($run),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($links, SORT_REGULAR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessVerificationOverall(OperationRun $run, ?array $report = null): ?string
|
||||||
|
{
|
||||||
|
$report ??= VerificationReportViewer::report($run);
|
||||||
|
$summary = is_array($report['summary'] ?? null) ? $report['summary'] : [];
|
||||||
|
$overall = $summary['overall'] ?? null;
|
||||||
|
|
||||||
|
return is_string($overall) && in_array($overall, VerificationReportOverall::values(), true)
|
||||||
|
? $overall
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessRunMatchesSelectedConnection(OperationRun $run, ?int $selectedProviderConnectionId): bool
|
||||||
|
{
|
||||||
|
if ($selectedProviderConnectionId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
$runProviderConnectionId = $this->normalizeReadinessInteger($context['provider_connection_id'] ?? null);
|
||||||
|
|
||||||
|
return $runProviderConnectionId !== null && $runProviderConnectionId === $selectedProviderConnectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessConnectionRecentlyUpdated(TenantOnboardingSession $draft): bool
|
||||||
|
{
|
||||||
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
|
|
||||||
|
return (bool) ($state['connection_recently_updated'] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readinessDraftHasTenantIdentity(TenantOnboardingSession $draft): bool
|
||||||
|
{
|
||||||
|
if ($draft->tenant_id !== null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
|
$entraTenantId = $state['entra_tenant_id'] ?? $draft->entra_tenant_id;
|
||||||
|
|
||||||
|
return is_string($entraTenantId) && trim($entraTenantId) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeReadinessInteger(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (is_int($value) && $value > 0) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($value) && ctype_digit(trim($value))) {
|
||||||
|
return (int) trim($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
$normalized = (int) $value;
|
||||||
|
|
||||||
|
return $normalized > 0 ? $normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, \Filament\Schemas\Components\Component>
|
* @return array<int, \Filament\Schemas\Components\Component>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -136,9 +136,9 @@
|
|||||||
|
|
||||||
@if (! $hasStoredPermissionData)
|
@if (! $hasStoredPermissionData)
|
||||||
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
|
<div class="rounded-xl border border-warning-200 bg-warning-50 p-4 text-sm text-warning-800 dark:border-warning-800 dark:bg-warning-950/30 dark:text-warning-200">
|
||||||
<div class="font-semibold">Keine Daten verfügbar</div>
|
<div class="font-semibold">No data available</div>
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
Für diesen Tenant liegen noch keine gespeicherten Verifikationsdaten vor.
|
No stored verification data is available for this tenant.
|
||||||
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
|
<a href="{{ $reRunUrl }}" class="font-medium underline">Start verification</a>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantPermission;
|
||||||
use App\Models\TenantOnboardingSession;
|
use App\Models\TenantOnboardingSession;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -14,12 +15,157 @@
|
|||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Verification\VerificationReportWriter;
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Testing\TestAction;
|
use Filament\Actions\Testing\TestAction;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
function managedReadinessPermissionKeys(): array
|
||||||
|
{
|
||||||
|
$configured = array_merge(
|
||||||
|
config('intune_permissions.permissions', []),
|
||||||
|
config('entra_permissions.permissions', []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map(static function (mixed $permission): ?string {
|
||||||
|
if (! is_array($permission)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $permission['key'] ?? null;
|
||||||
|
|
||||||
|
return is_string($key) && trim($key) !== '' ? trim($key) : null;
|
||||||
|
}, $configured)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedManagedReadinessPermissions(Tenant $tenant, ?int $staleDays = null, ?string $missingKey = null): ?string
|
||||||
|
{
|
||||||
|
$keys = managedReadinessPermissionKeys();
|
||||||
|
$missingKey ??= $keys[0] ?? null;
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if ($missingKey !== null && $key === $missingKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantPermission::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'permission_key' => $key,
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => ['source' => 'readiness-test'],
|
||||||
|
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $missingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: User, 1: TenantOnboardingSession, 2: ProviderConnection, 3: OperationRun|null, 4: string|null}
|
||||||
|
*/
|
||||||
|
function createManagedReadinessBlockerDraft(string $state): array
|
||||||
|
{
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => fake()->uuid(),
|
||||||
|
'name' => 'Blocker Tenant '.str_replace('_', ' ', $state),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connectionState = [
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'display_name' => 'Blocker connection',
|
||||||
|
'is_default' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($state === 'missing_consent') {
|
||||||
|
$connectionState['consent_status'] = ProviderConsentStatus::Required->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === 'revoked_consent') {
|
||||||
|
$connectionState['consent_status'] = ProviderConsentStatus::Revoked->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state === 'disabled_connection') {
|
||||||
|
$connectionState['is_enabled'] = false;
|
||||||
|
$connectionState['consent_status'] = ProviderConsentStatus::Granted->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->create($connectionState);
|
||||||
|
$run = null;
|
||||||
|
$missingKey = null;
|
||||||
|
|
||||||
|
if ($state === 'blocked_verification' || $state === 'permission_gap') {
|
||||||
|
$connection->forceFill([
|
||||||
|
'is_enabled' => true,
|
||||||
|
'consent_status' => ProviderConsentStatus::Granted->value,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$missingKey = seedManagedReadinessPermissions($tenant);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Blocked->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'permissions.admin_consent',
|
||||||
|
'title' => 'Required application permissions',
|
||||||
|
'status' => 'fail',
|
||||||
|
'severity' => 'critical',
|
||||||
|
'blocking' => true,
|
||||||
|
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
'message' => 'Missing required provider permissions.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'verify',
|
||||||
|
'state' => array_filter([
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => $run instanceof OperationRun ? (int) $run->getKey() : null,
|
||||||
|
], static fn (mixed $value): bool => $value !== null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
return [$user, $draft, $connection, $run, $missingKey];
|
||||||
|
}
|
||||||
|
|
||||||
it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void {
|
it('returns 404 for non-members when starting onboarding with a selected workspace', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@ -869,6 +1015,349 @@
|
|||||||
->assertSee('Draft Owner');
|
->assertSee('Draft Owner');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows route-bound readiness progress and check-not-run guidance with one primary next action', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create(['name' => 'Readiness Owner']);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '31313131-3131-3131-3131-313131313131',
|
||||||
|
'name' => 'No Check Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'display_name' => 'No check connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'verify',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Onboarding readiness')
|
||||||
|
->assertSee('Current checkpoint')
|
||||||
|
->assertSee('Verify access')
|
||||||
|
->assertSee('Verification has not run yet')
|
||||||
|
->assertSee('Provider connection')
|
||||||
|
->assertSee('Primary next action')
|
||||||
|
->assertSee('Start verification');
|
||||||
|
|
||||||
|
expect(substr_count($response->getContent(), 'Primary next action'))->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows route-bound ready readiness with freshness and canonical operation evidence', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '32323232-3232-3232-3232-323232323232',
|
||||||
|
'name' => 'Ready Readiness Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
seedManagedReadinessPermissions($tenant, missingKey: '__none__');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'display_name' => 'Ready connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'info',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Connection is healthy.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'complete',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Ready for activation')
|
||||||
|
->assertSee('Verification and permission evidence are current.')
|
||||||
|
->assertSee('Complete onboarding')
|
||||||
|
->assertSee('Supporting evidence')
|
||||||
|
->assertSee('Open operation')
|
||||||
|
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('classifies consent, disabled connection, and blocked verification readiness blockers', function (string $state, string $summary, string $nextAction): void {
|
||||||
|
[$user, $draft] = createManagedReadinessBlockerDraft($state);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Onboarding readiness')
|
||||||
|
->assertSee($summary)
|
||||||
|
->assertSee($nextAction);
|
||||||
|
})->with([
|
||||||
|
'missing consent' => ['missing_consent', 'Provider consent required', 'Grant consent'],
|
||||||
|
'revoked consent' => ['revoked_consent', 'Provider consent revoked', 'Grant consent'],
|
||||||
|
'disabled connection' => ['disabled_connection', 'Provider connection disabled', 'Review provider connection'],
|
||||||
|
'blocked verification' => ['blocked_verification', 'Permission or consent blocker needs attention', 'Review permissions'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('keeps permission gap diagnostics provider-owned while top-level readiness stays neutral', function (): void {
|
||||||
|
[$user, $draft, , , $missingKey] = createManagedReadinessBlockerDraft('permission_gap');
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Permission or consent blocker needs attention')
|
||||||
|
->assertSee('Permission diagnostics')
|
||||||
|
->assertSee('Missing application permissions')
|
||||||
|
->assertSee('Review permissions');
|
||||||
|
|
||||||
|
if (is_string($missingKey) && $missingKey !== '') {
|
||||||
|
$response->assertSee($missingKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->assertDontSee('Microsoft Graph readiness');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades route-bound readiness when permission evidence is stale and keeps the operation link canonical', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '52525252-5252-5252-5252-525252525252',
|
||||||
|
'name' => 'Stale Evidence Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
seedManagedReadinessPermissions($tenant, staleDays: 45, missingKey: '__none__');
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'display_name' => 'Stale readiness connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'info',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Connection is healthy.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'complete',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Readiness needs attention')
|
||||||
|
->assertSee('Permission data is older than the 30-day freshness window.')
|
||||||
|
->assertSee('Rerun verification')
|
||||||
|
->assertSee('Open operation')
|
||||||
|
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades route-bound readiness when verification evidence belongs to another selected connection', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '53535353-5353-5353-5353-535353535353',
|
||||||
|
'name' => 'Mismatched Evidence Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
seedManagedReadinessPermissions($tenant, missingKey: '__none__');
|
||||||
|
|
||||||
|
$oldConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => '54545454-5454-5454-5454-545454545454',
|
||||||
|
'display_name' => 'Previous connection',
|
||||||
|
]);
|
||||||
|
$selectedConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'display_name' => 'Selected connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $oldConnection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'info',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Connection is healthy.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'complete',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
'provider_connection_id' => (int) $selectedConnection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Verification needs refresh')
|
||||||
|
->assertSee('Verification evidence belongs to a different provider connection.')
|
||||||
|
->assertSee('Rerun verification')
|
||||||
|
->assertSee('Open operation')
|
||||||
|
->assertSee(route('admin.operations.view', ['run' => $run->getKey()]), false);
|
||||||
|
});
|
||||||
|
|
||||||
it('resumes an existing draft for the same tenant instead of creating a duplicate', function (): void {
|
it('resumes an existing draft for the same tenant instead of creating a duplicate', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@ -1171,6 +1660,10 @@
|
|||||||
$component->call('startVerification');
|
$component->call('startVerification');
|
||||||
$component->call('startVerification');
|
$component->call('startVerification');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSee('Onboarding readiness')
|
||||||
|
->assertSee('Open operation');
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('type', 'provider.connection.check')
|
->where('type', 'provider.connection.check')
|
||||||
|
|||||||
@ -259,6 +259,53 @@
|
|||||||
->assertActionEnabled('cancel_onboarding_draft');
|
->assertActionEnabled('cancel_onboarding_draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps destructive draft actions confirmation protected and capability gated', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
]);
|
||||||
|
$manager = User::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $manager,
|
||||||
|
role: 'manager',
|
||||||
|
workspaceRole: 'manager',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$resumableDraft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $manager,
|
||||||
|
'updated_by' => $manager,
|
||||||
|
]);
|
||||||
|
$cancelledDraft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $manager,
|
||||||
|
'updated_by' => $manager,
|
||||||
|
'status' => 'cancelled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
Livewire::actingAs($manager)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $resumableDraft->getKey(),
|
||||||
|
])
|
||||||
|
->assertActionExists('cancel_onboarding_draft', fn (\Filament\Actions\Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->assertActionEnabled('cancel_onboarding_draft');
|
||||||
|
|
||||||
|
Livewire::actingAs($manager)
|
||||||
|
->test(ManagedTenantOnboardingWizard::class, [
|
||||||
|
'onboardingDraft' => (int) $cancelledDraft->getKey(),
|
||||||
|
])
|
||||||
|
->assertActionExists('delete_onboarding_draft_header', fn (\Filament\Actions\Action $action): bool => $action->isConfirmationRequired())
|
||||||
|
->assertActionEnabled('delete_onboarding_draft_header');
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 404 for non-members when requesting a shared onboarding draft', function (): void {
|
it('returns 404 for non-members when requesting a shared onboarding draft', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
@ -320,6 +367,71 @@
|
|||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps readiness routes hidden from non-members and wrong workspaces while capability denials stay forbidden', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'status' => Tenant::STATUS_ONBOARDING,
|
||||||
|
'name' => 'Readiness Authorization Tenant',
|
||||||
|
]);
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$nonMember = User::factory()->create();
|
||||||
|
$wrongWorkspaceUser = User::factory()->create();
|
||||||
|
$readonly = User::factory()->create();
|
||||||
|
$wrongWorkspace = Workspace::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $owner,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $readonly,
|
||||||
|
role: 'readonly',
|
||||||
|
workspaceRole: 'readonly',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
\App\Models\WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $wrongWorkspace->getKey(),
|
||||||
|
'user_id' => (int) $wrongWorkspaceUser->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $owner,
|
||||||
|
'updated_by' => $owner,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $tenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $tenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($nonMember)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $wrongWorkspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($wrongWorkspaceUser)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($readonly)
|
||||||
|
->get(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
it('returns 403 for readonly members on cancelled draft summaries so delete controls never render', function (): void {
|
it('returns 403 for readonly members on cancelled draft summaries so delete controls never render', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$tenant = Tenant::factory()->create([
|
$tenant = Tenant::factory()->create([
|
||||||
|
|||||||
@ -2,11 +2,47 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantPermission;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\Verification\VerificationReportWriter;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
|
||||||
|
function seedPickerReadinessPermissions(Tenant $tenant, ?int $staleDays = null): void
|
||||||
|
{
|
||||||
|
$configured = array_merge(
|
||||||
|
config('intune_permissions.permissions', []),
|
||||||
|
config('entra_permissions.permissions', []),
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($configured as $permission) {
|
||||||
|
if (! is_array($permission)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = $permission['key'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($key) || trim($key) === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
TenantPermission::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'permission_key' => trim($key),
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => ['source' => 'picker-readiness-test'],
|
||||||
|
'last_checked_at' => $staleDays === null ? now() : now()->subDays($staleDays),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
it('shows a draft picker with resumable draft metadata when multiple drafts exist', function (): void {
|
it('shows a draft picker with resumable draft metadata when multiple drafts exist', function (): void {
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@ -124,3 +160,273 @@
|
|||||||
->assertDontSee('Completed Draft')
|
->assertDontSee('Completed Draft')
|
||||||
->assertDontSee('Cancelled Draft');
|
->assertDontSee('Cancelled Draft');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows compact readiness snippets for multiple resumable drafts while keeping picker actions', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$blockedTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '41414141-4141-4141-4141-414141414141',
|
||||||
|
'name' => 'Needs Connection Tenant',
|
||||||
|
]);
|
||||||
|
$readyTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '42424242-4242-4242-4242-424242424242',
|
||||||
|
'name' => 'Ready Picker Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$blockedTenant->getKey() => ['role' => 'owner'],
|
||||||
|
$readyTenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
seedPickerReadinessPermissions($readyTenant);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $readyTenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $readyTenant->tenant_id,
|
||||||
|
'display_name' => 'Ready picker connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $readyTenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'info',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Connection is healthy.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $blockedTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'connection',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $blockedTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $blockedTenant->name,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $readyTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'complete',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $readyTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $readyTenant->name,
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $run->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Needs Connection Tenant')
|
||||||
|
->assertSee('Ready Picker Tenant')
|
||||||
|
->assertSee('Compact readiness')
|
||||||
|
->assertSee('Provider connection required')
|
||||||
|
->assertSee('Connect provider')
|
||||||
|
->assertSee('Ready for activation')
|
||||||
|
->assertSee('Verification and permission evidence are current.')
|
||||||
|
->assertSee('Resume onboarding')
|
||||||
|
->assertSee('View summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows stale and mismatched readiness cues across multiple drafts in the picker', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staleTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '44444444-4444-4444-4444-444444444444',
|
||||||
|
'name' => 'Picker Stale Tenant',
|
||||||
|
]);
|
||||||
|
$mismatchTenant = Tenant::factory()->onboarding()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => '45454545-4545-4545-4545-454545454545',
|
||||||
|
'name' => 'Picker Mismatch Tenant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$staleTenant->getKey() => ['role' => 'owner'],
|
||||||
|
$mismatchTenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
seedPickerReadinessPermissions($staleTenant, staleDays: 45);
|
||||||
|
seedPickerReadinessPermissions($mismatchTenant);
|
||||||
|
|
||||||
|
$staleConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $staleTenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $staleTenant->tenant_id,
|
||||||
|
'display_name' => 'Stale picker connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
$oldMismatchConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $mismatchTenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => '46464646-4646-4646-4646-464646464646',
|
||||||
|
'display_name' => 'Old mismatch picker connection',
|
||||||
|
]);
|
||||||
|
$selectedMismatchConnection = ProviderConnection::factory()->platform()->verifiedHealthy()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $mismatchTenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => (string) $mismatchTenant->tenant_id,
|
||||||
|
'display_name' => 'Selected mismatch picker connection',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staleRun = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $staleTenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $staleConnection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'info',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Connection is healthy.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$mismatchRun = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'tenant_id' => (int) $mismatchTenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $oldMismatchConnection->getKey(),
|
||||||
|
'verification_report' => VerificationReportWriter::build('provider.connection.check', [
|
||||||
|
[
|
||||||
|
'key' => 'provider.connection.check',
|
||||||
|
'title' => 'Provider connection check',
|
||||||
|
'status' => 'pass',
|
||||||
|
'severity' => 'info',
|
||||||
|
'blocking' => false,
|
||||||
|
'reason_code' => 'ok',
|
||||||
|
'message' => 'Connection is healthy.',
|
||||||
|
'evidence' => [],
|
||||||
|
'next_steps' => [],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $staleTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'complete',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $staleTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $staleTenant->name,
|
||||||
|
'provider_connection_id' => (int) $staleConnection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $staleRun->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'tenant' => $mismatchTenant,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'current_step' => 'complete',
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => (string) $mismatchTenant->tenant_id,
|
||||||
|
'tenant_name' => (string) $mismatchTenant->name,
|
||||||
|
'provider_connection_id' => (int) $selectedMismatchConnection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $mismatchRun->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding'))
|
||||||
|
->assertSuccessful()
|
||||||
|
->assertSee('Picker Stale Tenant')
|
||||||
|
->assertSee('Picker Mismatch Tenant')
|
||||||
|
->assertSee('Permission data is older than the 30-day freshness window.')
|
||||||
|
->assertSee('Verification evidence belongs to a different provider connection.')
|
||||||
|
->assertSee('Rerun verification');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the single-draft landing redirect instead of rendering compact readiness', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $workspace,
|
||||||
|
'started_by' => $user,
|
||||||
|
'updated_by' => $user,
|
||||||
|
'state' => [
|
||||||
|
'entra_tenant_id' => '43434343-4343-4343-4343-434343434343',
|
||||||
|
'tenant_name' => 'Single Redirect Tenant',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get(route('admin.onboarding'))
|
||||||
|
->assertRedirect(route('admin.onboarding.draft', ['onboardingDraft' => $draft->getKey()]));
|
||||||
|
});
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Keine Daten verfügbar')
|
->assertSee('No data available')
|
||||||
->assertSee($expectedUrl, false)
|
->assertSee($expectedUrl, false)
|
||||||
->assertSee('Start verification');
|
->assertSee('Start verification');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -48,6 +48,41 @@
|
|||||||
->and($result->status())->toBe(404);
|
->and($result->status())->toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns not found for workspace members missing linked tenant entitlement', function (): void {
|
||||||
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$workspaceOnlyUser = User::factory()->create();
|
||||||
|
|
||||||
|
createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
user: $owner,
|
||||||
|
role: 'owner',
|
||||||
|
workspaceRole: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $workspaceOnlyUser->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$draft = createOnboardingDraft([
|
||||||
|
'workspace' => $tenant->workspace,
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'started_by' => $owner,
|
||||||
|
'updated_by' => $owner,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$result = app(TenantOnboardingSessionPolicy::class)->view($workspaceOnlyUser, $draft);
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(Response::class)
|
||||||
|
->and($result->allowed())->toBeFalse()
|
||||||
|
->and($result->status())->toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an honest forbidden message for entitled actors missing onboarding capability', function (): void {
|
it('returns an honest forbidden message for entitled actors missing onboarding capability', function (): void {
|
||||||
$tenant = Tenant::factory()->onboarding()->create();
|
$tenant = Tenant::factory()->onboarding()->create();
|
||||||
$readonlyUser = User::factory()->create();
|
$readonlyUser = User::factory()->create();
|
||||||
|
|||||||
@ -32,3 +32,19 @@
|
|||||||
|
|
||||||
expect($freshness['is_stale'])->toBeFalse();
|
expect($freshness['is_stale'])->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the thirty day freshness boundary exact to the second', function (): void {
|
||||||
|
$reference = CarbonImmutable::parse('2026-02-08 12:00:00');
|
||||||
|
|
||||||
|
$insideWindow = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||||
|
CarbonImmutable::parse('2026-01-09 12:00:01'),
|
||||||
|
$reference,
|
||||||
|
);
|
||||||
|
$outsideWindow = TenantRequiredPermissionsViewModelBuilder::deriveFreshness(
|
||||||
|
CarbonImmutable::parse('2026-01-09 11:59:59'),
|
||||||
|
$reference,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($insideWindow['is_stale'])->toBeFalse()
|
||||||
|
->and($outsideWindow['is_stale'])->toBeTrue();
|
||||||
|
});
|
||||||
|
|||||||
@ -77,6 +77,7 @@ ### Product Scalability & Self-Service Foundation
|
|||||||
- Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes
|
- Plans, Entitlements & Billing Readiness: plan model, feature gates, tenant/workspace/user/report/export/retention limits, trial state, grace periods, billing status, and audited plan changes
|
||||||
- Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate
|
- Demo & Trial Readiness: seeded demo workspaces, sample tenants, sample baselines/findings/reports, demo reset support, trial provisioning checklist, and sample-data mode where appropriate
|
||||||
- Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features
|
- Customer-facing transparency hooks: product surfaces should be designed so customer read-only views, review workspaces, support requests, and review-pack downloads can reuse the same underlying entities instead of becoming parallel one-off features
|
||||||
|
- Private AI readiness hooks: support, review, diagnostic, and decision surfaces should be designed so later AI assistance can use governed context builders, data classification, usage budgets, local/private model policies, cache fingerprints, and human approval gates instead of direct feature-level AI calls
|
||||||
|
|
||||||
**Active specs**: — (not yet specced)
|
**Active specs**: — (not yet specced)
|
||||||
|
|
||||||
@ -147,6 +148,7 @@ ### Solo-Founder SaaS Automation & Operating Readiness
|
|||||||
- AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup
|
- AVV / DPA / TOM / Legal Pack: reusable customer-facing legal and data-processing artifacts aligned with the actual product data model and hosting setup
|
||||||
- Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation
|
- Security Trust Pack Light: hosting overview, data categories, least-privilege permission model, RBAC model, retention, backup, audit logging, subprocessors, and “what we do not store” documentation
|
||||||
- Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs
|
- Support Desk + AI Triage: support mailbox or ticket system, categories, priorities, macros, known issues, AI triage, answer drafts, and linkage to TenantPilot diagnostic packs
|
||||||
|
- Private AI Operating Model: default no customer/tenant data to public AI APIs, local/private-first AI processing, explicit customer/workspace opt-in for non-local providers, AI disclosure wording, and operating rules for model hosting, retention, cost, and approval
|
||||||
- Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support
|
- Knowledge Base Pipeline: public docs, onboarding docs, troubleshooting docs, internal runbooks, and a maintained source set for AI-assisted support
|
||||||
- Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates
|
- Monitoring & Incident Runbooks: uptime, queues, failed jobs, error tracking, backups, storage, certificates, Graph failure rates, status page, incident templates, postmortem templates, and customer communication templates
|
||||||
- Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist
|
- Release & Customer Communication Automation: customer changelog, release notes, support notes, migration notes, breaking-change markers, known limitations, and docs-update checklist
|
||||||
@ -180,12 +182,38 @@ ### Product Usage, Customer Health & Operational Controls
|
|||||||
**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation.
|
**Depends on**: Self-Service Tenant Onboarding & Connection Readiness, Support Diagnostic Pack, Plans / Entitlements & Billing Readiness, ProviderConnection health, OperationRun truth, Findings workflow, StoredReports / EvidenceItems, and audit log foundation.
|
||||||
**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice.
|
**Scope direction**: Start with privacy-aware product telemetry, derived customer/workspace health indicators, and a minimal operational controls registry. Avoid building a full analytics platform, CRM, or customer-success suite in the first slice.
|
||||||
|
|
||||||
|
### Private AI Execution & Usage Governance Foundation
|
||||||
|
Strategic AI platform foundation for using AI inside TenantPilot without hard-coding public cloud AI calls, leaking tenant data, losing cost control, or forcing later rewrites.
|
||||||
|
**Goal**: Make AI local/private-first, explicitly governed, budgeted, cacheable, auditable, and human-approved. External public AI providers are disabled by default and only usable through workspace-level opt-in, data classification, redaction, usage limits, and approval gates.
|
||||||
|
**Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI cannot be bolted on through direct feature-level API calls. The platform needs a reusable execution boundary so support summaries, finding explanations, review packs, decision packs, and customer communications can use AI later without rebuilding privacy, cost, provider, approval, and audit controls each time.
|
||||||
|
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, Product Usage & Adoption Telemetry, Plans / Entitlements & Billing Readiness, Operational Controls & Feature Flags, Security Trust Pack Light, audit log foundation, and workspace/RBAC isolation.
|
||||||
|
**Scope direction**: Build the foundation before broad AI features: AI use case registry, AI provider registry, workspace AI policy, AI data classification, AI context builders, AI policy gate, AI budget gate, AI result store/cache, AI usage ledger, and AI audit trail. Start with local/private and customer-hosted model compatibility; keep external provider support optional and explicit.
|
||||||
|
|
||||||
|
**Core principles**:
|
||||||
|
- AI is never called directly from feature code; every AI action goes through governed use cases, policy gates, budget gates, context builders, provider adapters, cache/result storage, and audit trails
|
||||||
|
- Default posture: no customer or tenant data is sent to external public AI APIs
|
||||||
|
- Local/private/customer-hosted/EU-private model execution is the preferred path for tenant/customer data
|
||||||
|
- External public AI providers require explicit workspace opt-in, allowed data classes, redaction, budgets, disclosure, and human approval where needed
|
||||||
|
- Raw provider payloads, personal data, and customer-confidential context are never sent to external models by default
|
||||||
|
- Customer-facing, tenant-changing, risk-accepting, legal, or compliance-relevant AI outputs require human approval
|
||||||
|
- AI outputs should be fingerprinted, cacheable, source-linked, and reproducible enough for governance review
|
||||||
|
|
||||||
|
**Foundation components**:
|
||||||
|
- AI Use Case Registry: allowed data classes, model classes, approval requirements, output visibility, retention, cacheability, cost ceiling, and context-size limits per use case
|
||||||
|
- AI Provider Registry: disabled, local/private, customer-hosted OpenAI-compatible, TenantPilot-private, EU-private, and external provider adapters with trust-boundary metadata
|
||||||
|
- Workspace AI Policy: disabled, local-only, private-only, EU-only, external-allowed-with-redaction, or explicit external-allowed modes
|
||||||
|
- AI Data Classification: product knowledge, operational metadata, tenant config summary, redacted provider payload, raw provider payload, personal data, customer-confidential context, and legal/compliance statements
|
||||||
|
- AI Context Builders: sanitized, purpose-specific context assembly instead of sending raw tenant/provider data to models
|
||||||
|
- AI Usage Budgeting: credits, monthly caps, model-tier routing, queue priority, cost estimates, and cache/fingerprint reuse
|
||||||
|
- AI Result Store & Cache: fingerprinted outputs with provenance, freshness, invalidation, source references, and reuse controls
|
||||||
|
- AI Audit Trail: use case, provider, model class, data class, purpose, context hash, output hash, cost estimate, cache hit/miss, approval state, and result usage
|
||||||
|
|
||||||
### AI-Assisted Customer Operations
|
### AI-Assisted Customer Operations
|
||||||
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by human approval and product auditability.
|
AI-assisted customer operations layer for support, reviews, summaries, release communication, and customer-facing explanations, explicitly bounded by private AI execution policy, human approval, and product auditability.
|
||||||
**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval.
|
**Goal**: Use AI to prepare, summarize, classify, and draft customer operations work while keeping tenant-changing actions, customer commitments, legal statements, and external communications under human approval.
|
||||||
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation.
|
**Why it matters**: TenantPilot can stay lean only if support, customer reviews, release communication, and diagnostics are structured enough for AI assistance without becoming ungoverned automation or uncontrolled public-model data processing.
|
||||||
**Depends on**: Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
|
**Depends on**: Private AI Execution & Usage Governance Foundation, Product Knowledge & Contextual Help, Support Diagnostic Pack, In-App Support Request, StoredReports / EvidenceItems, Findings workflow maturity, release-note discipline, and company support-desk structure.
|
||||||
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
|
**Scope direction**: Start with AI-generated support summaries, finding explanations, tenant review summaries, diagnostic summaries, release-note drafts, and support-response drafts. Prefer local/private execution for tenant/customer data. Avoid autonomous tenant remediation, automatic risk acceptance, automatic legal commitments, or customer-facing messages without review.
|
||||||
|
|
||||||
### Decision-Based Operating Foundations
|
### Decision-Based Operating Foundations
|
||||||
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
Constitution hardening for decision-first governance, workflow-first navigation, surface taxonomy, and primary-vs-evidence surface refactoring.
|
||||||
@ -309,6 +337,9 @@ ## Infrastructure & Platform Debt
|
|||||||
| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails |
|
| No product usage/adoption telemetry yet | Founder cannot see onboarding drop-off, feature adoption, trial health, or support-triggering surfaces without manual investigation | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails |
|
| No customer health score yet | Churn, inactive customers, stale reviews, unhealthy provider connections, and unresolved high-risk findings may be noticed too late | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails |
|
| No explicit operational controls / feature flags lane | Incidents or risky features may require code changes or manual database intervention instead of safe operator controls | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
|
| No private AI execution foundation yet | Future AI features may call model providers directly, leak tenant context, become hard to audit, or require rewrites to support local/private models | Covered by Private AI Execution & Usage Governance Foundation |
|
||||||
|
| No AI usage budgeting / cost governance yet | AI-assisted summaries, decision packs, reviews, and support workflows may create uncontrolled compute/API costs and queue pressure | Covered by Private AI Execution & Usage Governance Foundation |
|
||||||
|
| No AI data classification / context-builder boundary yet | Raw provider payloads, personal data, or customer-confidential tenant context could be over-shared with models instead of sanitized purpose-specific context | Covered by Private AI Execution & Usage Governance Foundation |
|
||||||
| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails |
|
| No no-customization governance yet | Customer-specific requests can silently turn the product into consulting work and create hidden maintenance obligations | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails |
|
| No business-continuity / founder-backup plan yet | Solo-founder operations create continuity risk for incidents, illness, vacation, access recovery, and customer trust | Covered by Additional Solo-Founder Scale Guardrails |
|
||||||
| No `.env.example` in repo | Onboarding friction | Open |
|
| No `.env.example` in repo | Onboarding friction | Open |
|
||||||
@ -325,15 +356,16 @@ ## Priority Ranking (from Product Brainstorming)
|
|||||||
|
|
||||||
1. Product Scalability & Self-Service Foundation
|
1. Product Scalability & Self-Service Foundation
|
||||||
2. Product Usage, Customer Health & Operational Controls
|
2. Product Usage, Customer Health & Operational Controls
|
||||||
3. Decision-Based Operating / Governance Inbox
|
3. Private AI Execution & Usage Governance Foundation
|
||||||
4. MSP Portfolio + Alerting
|
4. Decision-Based Operating / Governance Inbox
|
||||||
5. Drift + Approval Workflows
|
5. MSP Portfolio + Alerting
|
||||||
6. Evidence / Review Packs + Customer Review Workspace
|
6. Drift + Approval Workflows
|
||||||
7. Standardization / Linting
|
7. Evidence / Review Packs + Customer Review Workspace
|
||||||
8. Promotion DEV→PROD
|
8. Standardization / Linting
|
||||||
9. Recovery Confidence
|
9. Promotion DEV→PROD
|
||||||
10. Solo-Founder SaaS Automation & Operating Readiness
|
10. Recovery Confidence
|
||||||
11. Additional Solo-Founder Scale Guardrails
|
11. Solo-Founder SaaS Automation & Operating Readiness
|
||||||
|
12. Additional Solo-Founder Scale Guardrails
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -344,5 +376,6 @@ ## How to use this file
|
|||||||
- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates.
|
- **Company automation / solo-founder operating items** live here as strategic tracks first; only product-impacting or repeatable engineering work should become spec candidates.
|
||||||
- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows.
|
- **Solo-founder guardrails** should remain visible even when they are not immediate product specs, because they define what must become measurable, controllable, delegable, or documented before customer volume grows.
|
||||||
- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions.
|
- **Governance positioning is Microsoft-first, provider-extensible**: roadmap language should keep the initial product scope focused on Microsoft tenant governance while avoiding unnecessary Microsoft-only coupling in platform-level abstractions.
|
||||||
|
- **AI positioning is local/private-first and provider-adapter-based**: roadmap language should avoid direct feature-level public AI calls and instead route AI through use-case registries, data classification, context builders, policy gates, budget gates, provider adapters, audit trails, and human approval workflows.
|
||||||
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
|
- **Small discoveries from implementation** → see [discoveries.md](discoveries.md)
|
||||||
- **Product principles** → see [principles.md](principles.md)
|
- **Product principles** → see [principles.md](principles.md)
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates and Additional Solo-Founder Scale Guardrails candidates from roadmap: Product Usage & Adoption Telemetry, Customer Health Score, Operational Controls & Feature Flags, Customer Lifecycle Communication, Product Intake & No-Customization Governance, and Data Retention / Export / Deletion Self-Service; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
> **Last reviewed**: 2026-04-25 (added Product Scalability & Self-Service Foundation candidates, Additional Solo-Founder Scale Guardrails candidates, Microsoft-first provider-extensible Decision-Based Operating candidates, and Private AI Execution & Usage Governance Foundation candidates; retained Codebase Quality & Engineering Maturity cluster and existing strategic hardening lanes)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -84,12 +84,16 @@ ## Qualified
|
|||||||
> 2. **Support Diagnostic Pack**
|
> 2. **Support Diagnostic Pack**
|
||||||
> 3. **Product Usage & Adoption Telemetry**
|
> 3. **Product Usage & Adoption Telemetry**
|
||||||
> 4. **Operational Controls & Feature Flags**
|
> 4. **Operational Controls & Feature Flags**
|
||||||
> 5. **Provider Identity & Target Scope Neutrality**
|
> 5. **Private AI Execution & Policy Foundation**
|
||||||
> 6. **Canonical Operation Type Source of Truth**
|
> 6. **AI Usage Budgeting, Context & Result Governance**
|
||||||
> 7. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
> 7. **Decision-Based Governance Inbox v1**
|
||||||
> 8. **Customer Review Workspace v1**
|
> 8. **Decision Pack Contract & Approval Workflow**
|
||||||
|
> 9. **Provider Identity & Target Scope Neutrality**
|
||||||
|
> 10. **Canonical Operation Type Source of Truth**
|
||||||
|
> 11. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
||||||
|
> 12. **Customer Review Workspace v1**
|
||||||
>
|
>
|
||||||
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support and lack of product-side observability/control. Self-service onboarding, diagnostic packs, adoption telemetry, and operational controls therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, and safe to run with low headcount.
|
> Rationale: the repo already has strong baseline, findings, evidence, review, operation-run, and operator foundations. With Canonical Control Catalog Foundation and Provider Boundary Hardening now specced, the immediate remaining product risk is not only semantic drift in provider identity, operation-type dual semantics, and governed-subject key leakage; it is also founder-dependent onboarding/support, lack of product-side observability/control, ungoverned AI introduction risk, and customer-facing search-and-troubleshoot workflows. Self-service onboarding, diagnostic packs, adoption telemetry, operational controls, private AI execution governance, and a decision-based governance inbox therefore move ahead of broader expansion so TenantPilot becomes repeatably operable, measurable, AI-ready, and safe to run with low headcount while customers receive decision-ready work instead of raw troubleshooting surfaces.
|
||||||
|
|
||||||
|
|
||||||
> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track.
|
> Product Scalability & Self-Service Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to keep TenantPilot operable as a low-headcount, AI-assisted SaaS by productizing recurring onboarding, support, diagnostics, entitlement, help, demo, and customer-operations work. This cluster should not become a generic backoffice automation program. Only product-impacting or repeatable engineering work belongs here; pure company-ops work stays in the roadmap / operating system track.
|
||||||
@ -326,8 +330,8 @@ ### AI-Assisted Customer Operations
|
|||||||
- AI hallucination risk must be mitigated through structured inputs and source references
|
- AI hallucination risk must be mitigated through structured inputs and source references
|
||||||
- Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider
|
- Privacy and data-processing boundaries need explicit review before customer data is sent to any model provider
|
||||||
- The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable
|
- The first version should probably be internal-only until diagnostics, knowledge, and support-request foundations are stable
|
||||||
- **Dependencies**: Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review
|
- **Dependencies**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Support Diagnostic Pack, Product Knowledge & Contextual Help, In-App Support Request with Context, StoredReports / EvidenceItems, Findings workflow, release communication process, security/privacy review
|
||||||
- **Related specs / candidates**: Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light
|
- **Related specs / candidates**: Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, Human-in-the-Loop Autonomous Governance, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Security Trust Pack Light
|
||||||
- **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist.
|
- **Strategic sequencing**: Mid-term. Do not promote before diagnostic, knowledge, and support-context foundations exist.
|
||||||
- **Priority**: medium
|
- **Priority**: medium
|
||||||
|
|
||||||
@ -429,7 +433,7 @@ ### Operational Controls & Feature Flags
|
|||||||
- Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower
|
- Operational controls must not bypass entitlement/RBAC semantics or become an untracked superpower
|
||||||
- Too many flags can create configuration drift; start with high-risk controls only
|
- Too many flags can create configuration drift; start with high-risk controls only
|
||||||
- Read-only modes need careful definition so evidence/audit access remains available
|
- Read-only modes need careful definition so evidence/audit access remains available
|
||||||
- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, audit log foundation, Plans / Entitlements & Billing Readiness
|
- **Dependencies**: System Panel Least-Privilege Capability Model, Provider-Backed Action Preflight and Dispatch Gate Unification, restore/provider action services, export/report services, AI execution controls, audit log foundation, Plans / Entitlements & Billing Readiness
|
||||||
- **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan
|
- **Related specs / candidates**: Provider-Backed Action Preflight and Dispatch Gate Unification, Plans / Entitlements & Billing Readiness, System Panel Least-Privilege Capability Model, Business Continuity / Founder Backup Plan
|
||||||
- **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate.
|
- **Strategic sequencing**: High priority once external customers or pilots depend on production. Can be promoted before telemetry if incident-control risk becomes immediate.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
@ -535,7 +539,84 @@ ### Data Retention, Export & Deletion Self-Service
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
> Microsoft-first, Provider-extensible Decision-Based Operating cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to move TenantPilot from Microsoft tenant search-and-troubleshoot workflows to decision-based governance operations. Customers and operators should not have to click through tenants, runs, findings, reports, logs, and evidence to discover what needs attention. TenantPilot should detect relevant work, gather context, summarize impact, propose safe next actions, and present decision-ready items. The first product scope stays Microsoft tenant governance; the decision model should avoid hard-coding Microsoft-only assumptions where provider-neutral abstractions already exist.
|
|
||||||
|
|
||||||
|
> Private AI Execution & Usage Governance Foundation cluster: these candidates come from the roadmap update on 2026-04-25. The goal is to make AI a governed platform capability, not a set of direct feature-level public API calls. TenantPilot should be local/private-first for tenant/customer data, provider-adapter-based, budgeted, cacheable, auditable, and human-approved where risk matters. External public AI providers must be disabled by default and only usable through explicit workspace policy, data classification, redaction, budget limits, and approval gates.
|
||||||
|
|
||||||
|
### Private AI Execution & Policy Foundation
|
||||||
|
- **Type**: AI platform foundation / privacy boundary / provider abstraction
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Private AI Execution & Usage Governance Foundation
|
||||||
|
- **Problem**: Future AI-assisted summaries, diagnostics, review packs, decision packs, support responses, and customer communications will be risky if individual features call model providers directly. Direct calls would make it hard to support local/private models, enforce data boundaries, audit usage, control costs, or answer German enterprise customers' privacy and compliance questions.
|
||||||
|
- **Why it matters**: TenantPilot sells governance, compliance readiness, evidence, and tenant trust. AI must therefore be governed like a platform capability: use-case registered, data-classified, policy-gated, budget-gated, provider-adapted, audited, and human-approved where needed. The architecture must support local/private/customer-hosted/EU-private models without later rewrites.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce an AI Use Case Registry for approved AI use cases such as finding summaries, operation summaries, support diagnostic summaries, review-pack executive summaries, decision-pack recommendations, and release/customer communication drafts
|
||||||
|
- introduce an AI Provider Registry with provider classes such as disabled, local/private, customer-hosted OpenAI-compatible, TenantPilot-private, EU-private, and external public provider adapters
|
||||||
|
- introduce Workspace AI Policy modes such as disabled, local-only, private-only, EU-only, external-allowed-with-redaction, and explicit external-allowed
|
||||||
|
- introduce AI Data Classification for product knowledge, operational metadata, tenant config summaries, redacted provider payloads, raw provider payloads, personal data, customer-confidential context, and legal/compliance statements
|
||||||
|
- ensure AI execution is only possible through a central policy gate and provider adapter, never direct feature-level model calls
|
||||||
|
- default external public AI providers to disabled for customer/tenant data
|
||||||
|
- define capability/RBAC boundaries for managing AI settings and viewing AI execution metadata
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: AI use-case registry, AI provider registry, workspace AI policy, AI data classification, policy evaluation service, provider adapter interface, initial disabled/local/private-compatible provider seam, RBAC/capability checks, and audit metadata shape
|
||||||
|
- **Out of scope**: building a full AI chatbot, implementing every provider, model benchmarking, autonomous remediation, legal final approval of AI disclosures, customer-facing AI UI for all use cases, or sending real tenant data to external providers
|
||||||
|
- **Acceptance points**:
|
||||||
|
- feature code cannot invoke AI without going through the central AI execution boundary
|
||||||
|
- every AI request declares use case, workspace, data class, model/provider class, purpose, and output visibility
|
||||||
|
- external public providers are disabled by default for tenant/customer data
|
||||||
|
- workspace AI policy can block or allow AI execution modes predictably
|
||||||
|
- raw provider payload and personal/customer-confidential data classes are rejected for external public providers by default
|
||||||
|
- AI policy decisions are auditable with actor/system actor, workspace, use case, provider class, data class, and decision outcome
|
||||||
|
- tests prove a disallowed provider/data-class combination cannot execute
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- The first version must avoid overbuilding a provider marketplace before real AI use cases exist
|
||||||
|
- Local/private model support may initially be an adapter seam rather than a fully operated inference stack
|
||||||
|
- Workspace AI policy must be simple enough for operators but precise enough for enterprise trust conversations
|
||||||
|
- Data classification must align with Security Trust Pack Light and actual stored product data
|
||||||
|
- **Dependencies**: Security Trust Pack Light, Product Knowledge & Contextual Help, Support Diagnostic Pack, Decision Pack Contract & Approval Workflow, audit log foundation, workspace/RBAC isolation, Operational Controls & Feature Flags
|
||||||
|
- **Related specs / candidates**: AI Usage Budgeting, Context & Result Governance, AI-Assisted Customer Operations, Decision Pack Contract & Approval Workflow, Support Diagnostic Pack, Security Trust Pack Light
|
||||||
|
- **Strategic sequencing**: Should land before broad AI-assisted customer operations or decision recommendations. This is the safety and provider boundary for all later AI features.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### AI Usage Budgeting, Context & Result Governance
|
||||||
|
- **Type**: AI cost governance / context governance / result lifecycle foundation
|
||||||
|
- **Source**: roadmap update 2026-04-25 — Private AI Execution & Usage Governance Foundation
|
||||||
|
- **Problem**: Even with local/private models, AI usage consumes compute, queue capacity, latency budget, and potentially paid provider credits. Without usage budgeting, context builders, redaction, fingerprinting, result caching, and output governance, AI-assisted support, reviews, decision packs, and summaries can become expensive, slow, inconsistent, or unsafe.
|
||||||
|
- **Why it matters**: AI-native SaaS margins depend on treating AI calls as metered, prioritized, cacheable product operations. TenantPilot also needs to avoid sending raw provider payloads or excessive customer context to models. Structured context builders and result governance make AI outputs safer, cheaper, more stable, and easier to audit.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- introduce an AI Usage Ledger for use case, workspace, tenant/reference, provider class, model class, data class, token/compute estimate, credit/cost estimate, queue priority, cache hit/miss, status, and purpose
|
||||||
|
- introduce AI credits or budget counters at workspace/plan level, with monthly caps, soft/hard limits, and operator override semantics
|
||||||
|
- introduce model-tier routing so low-risk summarization can use cheaper/local models while high-value decision recommendations can require stronger reasoning models and approval
|
||||||
|
- introduce purpose-specific AI Context Builders for finding, drift, operation run, support diagnostic, review pack, and decision pack use cases
|
||||||
|
- introduce redaction and minimization rules so context is sanitized, referenced, or summarized instead of passing raw tenant/provider data
|
||||||
|
- introduce AI Result Store & Cache keyed by fingerprints such as finding fingerprint, drift fingerprint, operation-run context hash, report fingerprint, evidence bundle fingerprint, and decision-pack fingerprint
|
||||||
|
- introduce approval gates and lifecycle states for customer-facing, legal/compliance, risk-accepting, or tenant-changing AI outputs
|
||||||
|
- expose basic operator visibility into AI usage, budget status, cache reuse, and blocked/failed AI jobs
|
||||||
|
- **Scope boundaries**:
|
||||||
|
- **In scope**: usage ledger, budget/credit service, context-builder contracts for first use cases, redaction hooks, result cache/store, fingerprinting, basic model-tier routing, queue priority metadata, approval state for sensitive outputs, and tests
|
||||||
|
- **Out of scope**: full billing integration, public customer AI usage dashboard, complex cost accounting, prompt marketplace, model fine-tuning, autonomous execution, or broad AI observability suite
|
||||||
|
- **Acceptance points**:
|
||||||
|
- AI jobs are recorded in a ledger with use case, workspace, provider/model class, data class, status, cache hit/miss, and cost/credit estimate
|
||||||
|
- workspace/plan AI budgets can block or degrade non-critical AI jobs when limits are exceeded
|
||||||
|
- at least one AI use case uses a context builder instead of raw model input from feature code
|
||||||
|
- result cache/fingerprint reuse prevents repeated generation for unchanged inputs
|
||||||
|
- customer-facing or risk-relevant AI outputs can remain draft/pending approval before use
|
||||||
|
- tests prove budget enforcement, cache reuse, redaction boundary, and approval-required output behavior
|
||||||
|
- **Risks / open questions**:
|
||||||
|
- Cost estimation may be approximate for local/private models; the system should support both token-cost and compute-credit abstractions
|
||||||
|
- Over-caching could reuse stale summaries if invalidation rules are weak
|
||||||
|
- Under-caching could make AI features too expensive and inconsistent
|
||||||
|
- Approval gates should not create UX friction for low-risk internal summaries
|
||||||
|
- **Dependencies**: Private AI Execution & Policy Foundation, Plans / Entitlements & Billing Readiness, Product Usage & Adoption Telemetry, Support Diagnostic Pack, StoredReports / EvidenceItems, Decision Pack Contract & Approval Workflow, OperationRun truth
|
||||||
|
- **Related specs / candidates**: AI-Assisted Customer Operations, Customer Lifecycle Communication, Product Knowledge & Contextual Help, Operational Controls & Feature Flags, Decision-Based Governance Inbox v1
|
||||||
|
- **Strategic sequencing**: Should follow or pair with Private AI Execution & Policy Foundation. It should land before AI is used at scale for reviews, support, decision packs, or customer communication.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
> Recommended sequence for this cluster:
|
||||||
|
> 1. **Private AI Execution & Policy Foundation**
|
||||||
|
> 2. **AI Usage Budgeting, Context & Result Governance**
|
||||||
|
> 3. **AI-Assisted Customer Operations**
|
||||||
|
>
|
||||||
|
> Why this order: first establish the trust boundary and provider/data policy, then add cost/context/result controls, and only then scale AI-assisted customer operations on top of governed inputs, budgets, caches, audits, and approvals.
|
||||||
|
|
||||||
### Decision-Based Governance Inbox v1
|
### Decision-Based Governance Inbox v1
|
||||||
- **Type**: product strategy / workflow automation / operator UX
|
- **Type**: product strategy / workflow automation / operator UX
|
||||||
@ -595,7 +676,7 @@ ### Decision Pack Contract & Approval Workflow
|
|||||||
- Too much context can overwhelm operators; the pack must be concise with progressive disclosure
|
- Too much context can overwhelm operators; the pack must be concise with progressive disclosure
|
||||||
- Recommendations must not overstate certainty; confidence/freshness must be visible
|
- Recommendations must not overstate certainty; confidence/freshness must be visible
|
||||||
- AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature
|
- AI-generated recommendations should remain optional and clearly marked until AI governance boundaries are mature
|
||||||
- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation
|
- **Dependencies**: Decision-Based Governance Inbox v1, Support Diagnostic Pack, Product Knowledge & Contextual Help, Private AI Execution & Policy Foundation, AI Usage Budgeting, Context & Result Governance, OperationRun link contract, Findings workflow, StoredReports / EvidenceItems, Operational Controls & Feature Flags, audit log foundation
|
||||||
- **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication
|
- **Related specs / candidates**: AI-Assisted Customer Operations, Operator Explanation Layer, Humanized Diagnostic Summaries for Governance Operations, Provider-Backed Action Preflight and Dispatch Gate Unification, Customer Lifecycle Communication
|
||||||
- **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready.
|
- **Strategic sequencing**: Should follow or pair with Governance Inbox v1. The inbox defines the work queue; decision packs make each item decision-ready.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
# Specification Quality Checklist: Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-25
|
||||||
|
**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
|
||||||
|
|
||||||
|
- Quality pass completed on 2026-04-25.
|
||||||
|
- The spec stays intentionally narrow: it reuses existing onboarding session, provider connection, verification, and checkpoint truth and does not introduce new onboarding persistence.
|
||||||
|
- Provider-specific Microsoft details remain contextual inside diagnostics instead of becoming new platform-core truth.
|
||||||
|
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||||
@ -0,0 +1,266 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantPilot Admin — Onboarding Readiness Workflow (Conceptual)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Conceptual HTTP contract for the operator-facing onboarding readiness workflow.
|
||||||
|
|
||||||
|
NOTE: These routes are implemented as Filament (Livewire) pages and existing
|
||||||
|
actions. The exact Livewire payload shape is not part of this contract; this
|
||||||
|
file captures the user-visible routes, authorization semantics, and logical
|
||||||
|
view-model expectations.
|
||||||
|
servers:
|
||||||
|
- url: /admin
|
||||||
|
paths:
|
||||||
|
/onboarding:
|
||||||
|
get:
|
||||||
|
summary: View onboarding landing or draft picker
|
||||||
|
description: |
|
||||||
|
Workspace-scoped onboarding entry point.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- No workspace selected: redirect to `/admin/choose-workspace`
|
||||||
|
- Non-member or wrong workspace: 404
|
||||||
|
- Workspace member without onboarding capability: 403
|
||||||
|
- One resumable draft: redirect to `/admin/onboarding/{onboardingDraft}`
|
||||||
|
- Multiple resumable drafts: render the draft picker with compact readiness snippets
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Landing picker rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/OnboardingLandingView'
|
||||||
|
'302':
|
||||||
|
description: Redirect to choose-workspace or the single resumable draft
|
||||||
|
'403':
|
||||||
|
description: Forbidden (workspace member lacks onboarding capability)
|
||||||
|
'404':
|
||||||
|
description: Not found (non-member or wrong workspace)
|
||||||
|
/onboarding/{onboardingDraft}:
|
||||||
|
get:
|
||||||
|
summary: View onboarding draft readiness workflow
|
||||||
|
description: |
|
||||||
|
Renders the existing managed-tenant onboarding wizard with a derived
|
||||||
|
readiness summary, freshness cues, and one primary next action.
|
||||||
|
|
||||||
|
Authorization:
|
||||||
|
- Non-member or wrong workspace: 404
|
||||||
|
- Missing linked-tenant entitlement: 404
|
||||||
|
- Workspace member without onboarding capability: 403
|
||||||
|
parameters:
|
||||||
|
- name: onboardingDraft
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Internal `managed_tenant_onboarding_sessions.id`
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Onboarding draft workflow rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
x-logical-view-model:
|
||||||
|
$ref: '#/components/schemas/OnboardingReadinessView'
|
||||||
|
'403':
|
||||||
|
description: Forbidden (workspace member lacks onboarding capability)
|
||||||
|
'404':
|
||||||
|
description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement)
|
||||||
|
/onboarding/{onboardingDraft}/actions/start-verification:
|
||||||
|
post:
|
||||||
|
summary: Start or rerun verification from the onboarding readiness workflow
|
||||||
|
description: |
|
||||||
|
Conceptual contract for the existing wizard verification action.
|
||||||
|
This feature must preserve current authorization, audit, dedupe, and
|
||||||
|
shared OperationRun start UX semantics.
|
||||||
|
parameters:
|
||||||
|
- name: onboardingDraft
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Verification accepted/queued
|
||||||
|
'403':
|
||||||
|
description: Forbidden (member lacks verification-start capability)
|
||||||
|
'404':
|
||||||
|
description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement)
|
||||||
|
/onboarding/{onboardingDraft}/actions/complete:
|
||||||
|
post:
|
||||||
|
summary: Complete onboarding when readiness allows activation
|
||||||
|
description: |
|
||||||
|
Conceptual contract for the existing owner-gated completion action.
|
||||||
|
The action remains confirmation-protected and audited.
|
||||||
|
parameters:
|
||||||
|
- name: onboardingDraft
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: Onboarding completed
|
||||||
|
'403':
|
||||||
|
description: Forbidden (member lacks activation capability)
|
||||||
|
'404':
|
||||||
|
description: Not found (non-member, wrong workspace, or missing linked-tenant entitlement)
|
||||||
|
/operations/{run}:
|
||||||
|
get:
|
||||||
|
summary: Open canonical supporting operation from onboarding readiness
|
||||||
|
description: |
|
||||||
|
Existing canonical tenantless operation-detail route linked from the
|
||||||
|
onboarding readiness workflow when supporting verification or bootstrap
|
||||||
|
evidence exists.
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
description: Internal `operation_runs.id`
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Operation detail rendered
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'403':
|
||||||
|
description: Forbidden (member lacks permission for an action on the page)
|
||||||
|
'404':
|
||||||
|
description: Not found (run inaccessible under current workspace/tenant scope)
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
OnboardingLandingView:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- mode
|
||||||
|
- drafts
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [start_state, single_redirect, draft_picker]
|
||||||
|
drafts:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OnboardingDraftCard'
|
||||||
|
primary_action:
|
||||||
|
$ref: '#/components/schemas/NextAction'
|
||||||
|
nullable: true
|
||||||
|
OnboardingDraftCard:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- draft_id
|
||||||
|
- tenant_name
|
||||||
|
- current_stage
|
||||||
|
- readiness_summary
|
||||||
|
- next_action
|
||||||
|
properties:
|
||||||
|
draft_id:
|
||||||
|
type: integer
|
||||||
|
tenant_name:
|
||||||
|
type: string
|
||||||
|
current_stage:
|
||||||
|
type: string
|
||||||
|
readiness_summary:
|
||||||
|
type: string
|
||||||
|
freshness_note:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
next_action:
|
||||||
|
$ref: '#/components/schemas/NextAction'
|
||||||
|
OnboardingReadinessView:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- draft
|
||||||
|
- readiness
|
||||||
|
- next_action
|
||||||
|
properties:
|
||||||
|
draft:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- tenant_name
|
||||||
|
- current_stage
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
tenant_name:
|
||||||
|
type: string
|
||||||
|
current_stage:
|
||||||
|
type: string
|
||||||
|
started_by:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
updated_by:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
readiness:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- lifecycle_state
|
||||||
|
- summary
|
||||||
|
properties:
|
||||||
|
lifecycle_state:
|
||||||
|
type: string
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
checkpoint:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
provider_summary:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
freshness_note:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
blocker_reason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
next_action:
|
||||||
|
$ref: '#/components/schemas/NextAction'
|
||||||
|
supporting_links:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/LinkAction'
|
||||||
|
NextAction:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- kind
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- start_onboarding
|
||||||
|
- resume_draft
|
||||||
|
- grant_consent
|
||||||
|
- review_permissions
|
||||||
|
- start_verification
|
||||||
|
- rerun_verification
|
||||||
|
- open_operation
|
||||||
|
- review_bootstrap
|
||||||
|
- complete_onboarding
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
action_name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
LinkAction:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- url
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
140
specs/240-tenant-onboarding-readiness/data-model.md
Normal file
140
specs/240-tenant-onboarding-readiness/data-model.md
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
# Data Model — Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
**Spec**: [spec.md](spec.md)
|
||||||
|
|
||||||
|
No new persistent tables are required for this slice. Readiness is computed at render time from existing onboarding, provider connection, verification, and permission-posture truth.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### TenantOnboardingSession (`managed_tenant_onboarding_sessions`)
|
||||||
|
|
||||||
|
**Purpose**: Workspace-scoped onboarding workflow record that owns resumability, checkpoint progression, and links to the managed tenant once identified.
|
||||||
|
|
||||||
|
**Key fields (existing)**:
|
||||||
|
- `workspace_id` (required FK)
|
||||||
|
- `tenant_id` (nullable FK to `tenants.id` until the tenant is linked)
|
||||||
|
- `entra_tenant_id`
|
||||||
|
- `current_step`
|
||||||
|
- `version`
|
||||||
|
- `lifecycle_state`
|
||||||
|
- `current_checkpoint`
|
||||||
|
- `last_completed_checkpoint`
|
||||||
|
- `reason_code`
|
||||||
|
- `blocking_reason_code`
|
||||||
|
- `completed_at`, `cancelled_at`
|
||||||
|
- `state` JSON, constrained by `TenantOnboardingSession::STATE_ALLOWED_KEYS`
|
||||||
|
|
||||||
|
**Relevant `state` keys (existing)**:
|
||||||
|
- `tenant_name`
|
||||||
|
- `primary_domain`
|
||||||
|
- `provider_connection_id`
|
||||||
|
- `selected_provider_connection_id`
|
||||||
|
- `verification_operation_run_id`
|
||||||
|
- `bootstrap_operation_types`
|
||||||
|
- `bootstrap_operation_runs`
|
||||||
|
- `connection_recently_updated`
|
||||||
|
|
||||||
|
**Relationships (existing)**:
|
||||||
|
- Belongs to `Workspace`
|
||||||
|
- May belong to `Tenant`
|
||||||
|
- Belongs to `startedByUser`
|
||||||
|
- Belongs to `updatedByUser`
|
||||||
|
|
||||||
|
### ProviderConnection (`provider_connections`)
|
||||||
|
|
||||||
|
**Purpose**: Tenant-owned provider access record whose consent, verification, and target-scope state inform onboarding readiness.
|
||||||
|
|
||||||
|
**Key fields (existing, relevant)**:
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `provider`
|
||||||
|
- `display_name`
|
||||||
|
- `connection_type`
|
||||||
|
- `is_default`
|
||||||
|
- `is_enabled`
|
||||||
|
- `consent_status`
|
||||||
|
- `verification_status`
|
||||||
|
- target-scope identity fields consumed by `ProviderConnectionTargetScopeNormalizer`
|
||||||
|
|
||||||
|
**Relationships / invariants (existing)**:
|
||||||
|
- The selected provider connection must belong to the same workspace and tenant as the onboarding draft.
|
||||||
|
- `ProviderConnectionSurfaceSummary::forConnection()` is the shared source for provider summary wording and contextual identity detail.
|
||||||
|
|
||||||
|
### VerificationRunEvidence (`operation_runs`, existing subset)
|
||||||
|
|
||||||
|
**Purpose**: Existing supporting evidence for verification and bootstrap readiness, including canonical operation detail links.
|
||||||
|
|
||||||
|
**Key fields (existing, relevant)**:
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `context.provider_connection_id`
|
||||||
|
- `context.verification_report`
|
||||||
|
- `summary_counts`
|
||||||
|
|
||||||
|
**Constraints / invariants (existing)**:
|
||||||
|
- The verification run must belong to the same workspace and tenant as the onboarding draft.
|
||||||
|
- Verification evidence is only trustworthy for readiness when `context.provider_connection_id` matches the draft’s selected provider connection.
|
||||||
|
- Canonical evidence links must continue to flow through `OperationRunLinks` / tenantless operation helpers.
|
||||||
|
|
||||||
|
### PermissionPostureOverview (derived from existing permission posture data)
|
||||||
|
|
||||||
|
**Purpose**: Existing stored permission comparison summary used by onboarding verification assist and readiness freshness cues.
|
||||||
|
|
||||||
|
**Source (existing)**:
|
||||||
|
- `TenantPermissionService::compare(...)`
|
||||||
|
- `TenantRequiredPermissionsViewModelBuilder::build(...)`
|
||||||
|
|
||||||
|
**Relevant derived fields (existing)**:
|
||||||
|
- `overview.overall`
|
||||||
|
- `overview.counts.missing_application`
|
||||||
|
- `overview.counts.missing_delegated`
|
||||||
|
- `overview.counts.error`
|
||||||
|
- `overview.freshness.last_refreshed_at`
|
||||||
|
- `overview.freshness.is_stale`
|
||||||
|
|
||||||
|
**Invariant (existing)**:
|
||||||
|
- Permission freshness is stale when no refresh exists or `last_refreshed_at` is older than 30 days (`TenantRequiredPermissionsViewModelBuilder::deriveFreshness`).
|
||||||
|
|
||||||
|
### OnboardingReadinessSummary (computed, not persisted)
|
||||||
|
|
||||||
|
**Purpose**: Operator-facing derived summary rendered on the onboarding landing picker and route-bound draft view.
|
||||||
|
|
||||||
|
**Proposed runtime shape (presentation-only)**:
|
||||||
|
- `draft`: `id`, `tenant_name`, `stage_label`, `draft_status_label`, `started_by`, `updated_by`, `last_updated_human`
|
||||||
|
- `checkpoint`: `current_checkpoint`, `last_completed_checkpoint`, `lifecycle_state`
|
||||||
|
- `provider_summary`: `readiness_summary`, `consent_state`, `verification_state`, `target_scope_summary`, `contextual_identity_line`
|
||||||
|
- `verification`: `status`, `overall`, `run_id`, `run_url`, `is_active`, `matches_selected_connection`
|
||||||
|
- `freshness`: `connection_recently_updated`, `verification_mismatch`, `permission_last_refreshed_at`, `permission_data_is_stale`
|
||||||
|
- `blocker`: `reason_code`, `blocking_reason_code`, `operator_summary`
|
||||||
|
- `next_action`: `label`, `kind`, `url_or_action`, `required_capability`
|
||||||
|
- `supporting_links`: `operation_url`, `tenant_url`, `consent_url` when already available from existing routes/helpers
|
||||||
|
|
||||||
|
**Important rule**: This is a presentation shape only. It must map directly from existing onboarding lifecycle, provider connection, verification, and permission-posture truth. It is not a new domain model or persisted state family.
|
||||||
|
|
||||||
|
## Derived Rules / Invariants
|
||||||
|
|
||||||
|
- A draft without tenant identity cannot be ready; the primary action remains the identify-tenant step.
|
||||||
|
- A draft without a selected provider connection cannot be ready; the primary action remains connect/select provider.
|
||||||
|
- A verification run that does not match the selected provider connection is stale for readiness and must force a non-ready outcome.
|
||||||
|
- `connection_recently_updated=true` invalidates previous verification trust until verification reruns.
|
||||||
|
- Stale permission posture (`overview.freshness.is_stale=true`) must surface as a readiness attention cue or diagnostic freshness cue, not as ready.
|
||||||
|
- Top-level readiness wording stays platform-neutral. Provider-specific permission names and consent instructions remain inside secondary diagnostics.
|
||||||
|
- Supporting evidence uses canonical operation links only; no page-local run URLs are introduced.
|
||||||
|
|
||||||
|
## Rendering Precedence (derived, not persisted)
|
||||||
|
|
||||||
|
No new persisted transitions are introduced. The readiness summary should follow this rendering precedence when choosing one primary next action:
|
||||||
|
|
||||||
|
1. No identified tenant: `Identify tenant`
|
||||||
|
2. No selected provider connection: `Connect provider`
|
||||||
|
3. Consent missing or revoked: `Grant consent`
|
||||||
|
4. Permission diagnostics blocked or incomplete: `Review permissions` / `Grant consent` as dictated by existing provider-owned diagnostics
|
||||||
|
5. Verification missing, stale, or mismatched: `Start verification` or `Rerun verification`
|
||||||
|
6. Verification active: `Open operation` or `Refresh`
|
||||||
|
7. Bootstrap selected and still active/failed: `Review bootstrap`
|
||||||
|
8. Lifecycle ready for activation: `Complete onboarding`
|
||||||
|
|
||||||
|
The multi-draft landing surface uses the same precedence, but only in compact form so the operator can choose the correct draft to open.
|
||||||
196
specs/240-tenant-onboarding-readiness/plan.md
Normal file
196
specs/240-tenant-onboarding-readiness/plan.md
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
# Implementation Plan: Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
**Branch**: `240-tenant-onboarding-readiness` | **Date**: 2026-04-25 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md`
|
||||||
|
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Add one operator-facing, derived readiness summary to the existing managed-tenant onboarding workflow: the route-bound draft at `/admin/onboarding/{onboardingDraft}` always shows full readiness, while the `/admin/onboarding` landing surface adds compact readiness only when multiple resumable drafts exist and preserves the existing single-draft redirect behavior.
|
||||||
|
- Reuse current onboarding draft lifecycle, provider connection summary, verification diagnostics, the existing 30-day permission freshness rule, and canonical OperationRun links instead of adding new persistence, readiness enums, or provider-generic onboarding frameworks.
|
||||||
|
- Keep the workflow DB-only at render time, workspace- and tenant-safe, and centered on one operator decision: what blocks this tenant now and what the next action is.
|
||||||
|
- Defer numeric completion scoring and any cross-surface readiness abstraction; later consumers must reuse the same derived onboarding truth instead of broadening this slice.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||||
|
**Primary Dependencies**: Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||||
|
**Storage**: PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned
|
||||||
|
**Testing**: Pest feature + unit tests (Filament/Livewire feature coverage plus policy/unit coverage)
|
||||||
|
**Validation Lanes**: fast-feedback
|
||||||
|
**Target Platform**: Sail-backed Laravel admin panel under `/admin`
|
||||||
|
**Project Type**: web
|
||||||
|
**Performance Goals**: onboarding landing and draft routes remain DB-only at render/hydration, compose readiness in-request from existing records, and avoid outbound HTTP or new queue starts during page render
|
||||||
|
**Constraints**: preserve workspace isolation, linked-tenant entitlement checks, existing RBAC capability boundaries, provider-boundary neutrality in top-level wording, current destructive confirmation patterns, current OperationRun start UX, and no new tables/enums/frameworks for readiness
|
||||||
|
**Scale/Scope**: one workspace-scoped onboarding workflow surface, one derived readiness composition, and focused onboarding/policy coverage only
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native Filament + shared primitives
|
||||||
|
- **Shared-family relevance**: status messaging, action links, badges, embedded diagnostics, navigation
|
||||||
|
- **State layers in scope**: page + workflow-step + derived summary
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: standard-native-filament
|
||||||
|
- **Required tests or manual smoke**: functional-core
|
||||||
|
- **Exception path and spread control**: existing guided-workflow exception for `ManagedTenantOnboardingWizard`; no new surface exemption
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Shared Pattern & System Fit
|
||||||
|
|
||||||
|
- **Cross-cutting feature marker**: yes
|
||||||
|
- **Systems touched**: `ManagedTenantOnboardingWizard`, onboarding landing/draft picker, `OnboardingLifecycleService`, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, `TenantRequiredPermissionsViewModelBuilder`, `OperationRunLinks`
|
||||||
|
- **Shared abstractions reused**: `OnboardingLifecycleService`, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, `TenantRequiredPermissionsViewModelBuilder`, `OperationRunLinks`, `OperationUxPresenter`, `ProviderOperationStartResultPresenter`
|
||||||
|
- **New abstraction introduced? why?**: none; readiness stays a thin derived composition on the existing workflow surface
|
||||||
|
- **Why the existing abstraction was sufficient or insufficient**: existing services already own checkpoint progression, provider connection wording, permission freshness, verification diagnostics, and canonical run links; the missing piece is a single operator-facing composition and action prioritization
|
||||||
|
- **Bounded deviation / spread control**: provider-specific consent and permission names remain inside existing diagnostic detail and provider-owned next steps; top-level readiness copy stays platform-neutral, and any shared builder extension must stay additive and behavior-preserving for non-onboarding consumers while onboarding-specific prioritization remains page-local
|
||||||
|
|
||||||
|
## OperationRun UX Impact
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes
|
||||||
|
- **Central contract reused**: shared OperationRun UX layer via `OperationRunLinks`, `OperationUxPresenter`, and the existing onboarding `tenantlessOperationRunUrl(...)` helper
|
||||||
|
- **Delegated UX behaviors**: queued toast, dedupe-or-blocked messaging, canonical `Open operation` link labeling, tenant/workspace-safe run URL resolution, and terminal lifecycle notifications remain delegated to shared Ops-UX paths
|
||||||
|
- **Surface-owned behavior kept local**: readiness wording, freshness explanation, and next-action prioritization inside the onboarding workflow
|
||||||
|
- **Queued DB-notification policy**: unchanged explicit opt-in; this slice does not add queued or running DB notifications
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism already used by reused verification/bootstrap actions
|
||||||
|
- **Exception path**: none
|
||||||
|
|
||||||
|
## Provider Boundary & Portability Fit
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Provider-owned seams**: consent diagnostics, missing-permission detail, provider-specific remediation next steps, target-scope identity detail
|
||||||
|
- **Platform-core seams**: readiness summary, checkpoint progress, next-action labels, freshness messaging, workflow routing
|
||||||
|
- **Neutral platform terms / contracts preserved**: `provider connection`, `readiness`, `diagnostics`, `next action`, `freshness`, `onboarding step`
|
||||||
|
- **Retained provider-specific semantics and why**: Microsoft permission names and consent language stay inside existing verification assist and provider summary detail because the operator needs exact remediation instructions for the supported provider
|
||||||
|
- **Bounded extraction or follow-up path**: none; if a second provider appears later, revisit only the provider-owned detail seam rather than the workflow shell
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Persisted truth / proportionality: PASS — readiness remains derived from existing onboarding, provider connection, operation-run, and permission-posture truth; no new table, enum, or state family is planned.
|
||||||
|
- Read/write separation / destructive actions: PASS — this slice changes operator understanding on an existing wizard; existing cancel/delete/complete actions retain current confirmation, audit, and authorization rules.
|
||||||
|
- Graph contract path / DB-only render: PASS — render and Livewire hydration stay DB-only, reusing stored provider connection, verification, and permission-posture data with no new Graph calls.
|
||||||
|
- RBAC / isolation: PASS — existing workspace membership, linked-tenant entitlement, and `Capabilities::*` gates remain authoritative; non-members and wrong-scope actors stay 404, capability-denied members stay 403.
|
||||||
|
- Ops-UX / OperationRun link semantics: PASS — existing verification/bootstrap starts and run links continue to use shared OperationRun UX and canonical tenantless route helpers; no new run-start path is introduced.
|
||||||
|
- Provider boundary / shared pattern reuse: PASS — provider-neutral readiness remains top-level, Microsoft-specific details stay contextual, and existing shared builders/helpers are reused before any local deviation.
|
||||||
|
- Test governance: PASS — proof stays in fast-feedback feature/unit coverage using existing onboarding fixtures; no new browser or heavy-governance family is required.
|
||||||
|
- Global search / panel registration: N/A — this slice changes a Filament page, not a global-searchable resource or panel provider.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Feature (wizard + landing/picker rendering and behavior) plus targeted Unit/Policy coverage for isolation semantics and existing freshness helper behavior where directly reused
|
||||||
|
- **Affected validation lanes**: fast-feedback
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: the workflow is server-driven Filament/Livewire UI backed by existing models and policies, so feature tests can prove readiness composition, next-action precedence, and deny semantics without browser automation
|
||||||
|
- **Narrowest proving command(s)**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: reuse existing onboarding draft, workspace membership, tenant membership, provider connection, and operation-run fixtures; avoid adding browser fixtures or new provider mocks
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; any new test helpers should stay onboarding-local
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native-filament guided-workflow exception already exists; add feature assertions to cover the readiness summary on both landing and draft routes
|
||||||
|
- **Closing validation and reviewer handoff**: rerun the targeted onboarding wizard, draft-picker/authorization, and policy/freshness tests after implementation; reviewers should verify one primary next action, correct 404 vs 403 semantics, and no render-time outbound HTTP
|
||||||
|
- **Budget / baseline / trend follow-up**: none expected beyond ordinary feature-local upkeep
|
||||||
|
- **Review-stop questions**: Does the slice stay in fast-feedback? Did any new fixture force browser/heavy coverage? Did the implementation introduce a second readiness taxonomy or persisted truth?
|
||||||
|
- **Escalation path**: document-in-feature if a shared helper needs a tiny extension; reject-or-split if implementation tries to add new persistence/frameworks or browser-lane proof
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: the planned proof extends already-existing onboarding suites and does not add a new recurring test cost center
|
||||||
|
|
||||||
|
### Implementation Close-Out — 2026-04-25
|
||||||
|
|
||||||
|
- **Guardrail result**: keep. The implementation stayed inside the existing guided onboarding page, reused current lifecycle/stage, provider summary, verification assist, permission freshness, and OperationRun link paths, and did not add persistence, enums, a readiness taxonomy, or a cross-surface framework.
|
||||||
|
- **Fast-feedback proof**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` passed with 55 tests and 212 assertions.
|
||||||
|
- **Provider-boundary / lane-cost decision**: keep. Provider-specific permission and consent detail remains secondary and provider-owned; top-level readiness wording stays platform-neutral. No browser, heavy-governance, new fixture family, or follow-up spec is required.
|
||||||
|
- **Explicit defers retained**: numeric completion score and cross-surface readiness reuse remain deferred; future consumers should reuse this derived onboarding truth rather than broaden this slice.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/240-tenant-onboarding-readiness/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── onboarding-readiness.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
│ ├── Models/TenantOnboardingSession.php
|
||||||
|
│ ├── Services/Intune/TenantRequiredPermissionsViewModelBuilder.php
|
||||||
|
│ ├── Services/Onboarding/
|
||||||
|
│ │ ├── OnboardingDraftStageResolver.php
|
||||||
|
│ │ └── OnboardingLifecycleService.php
|
||||||
|
│ ├── Support/OperationRunLinks.php
|
||||||
|
│ ├── Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php
|
||||||
|
│ └── Support/Verification/VerificationAssistViewModelBuilder.php
|
||||||
|
└── tests/
|
||||||
|
├── Feature/ManagedTenantOnboardingWizardTest.php
|
||||||
|
├── Feature/Onboarding/OnboardingDraftAuthorizationTest.php
|
||||||
|
├── Feature/Onboarding/OnboardingDraftPickerTest.php
|
||||||
|
├── Unit/Policies/TenantOnboardingSessionPolicyTest.php
|
||||||
|
└── Unit/TenantRequiredPermissionsFreshnessTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single Laravel web application. This feature remains confined to the existing onboarding wizard/landing workflow, existing onboarding + provider support services, and focused onboarding/policy tests.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations are required for this feature. The plan explicitly avoids new persistence, abstractions, readiness enums, or cross-surface frameworks.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
N/A — the plan intentionally keeps readiness derived and local to the existing onboarding workflow. No new persisted entity, abstraction layer, enum/status family, or taxonomy is proposed.
|
||||||
|
|
||||||
|
## Phase 0 — Research (output: `research.md`)
|
||||||
|
|
||||||
|
See: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/research.md`
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
- Confirm the narrowest existing sources of truth for onboarding readiness, draft landing metadata, provider summary, permission freshness, and canonical operation links.
|
||||||
|
- Resolve the freshness question by reusing the existing 30-day permission freshness rule plus current connection-change and selected-connection-mismatch signals rather than introducing a new onboarding-specific threshold or persisted freshness model.
|
||||||
|
- Confirm that landing-route behavior remains the same surface (single-draft redirect or multi-draft picker) so readiness context is added without creating a second onboarding register.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||||
|
|
||||||
|
See:
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/data-model.md`
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml`
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/quickstart.md`
|
||||||
|
|
||||||
|
Design focus:
|
||||||
|
- Add one derived readiness composition on `ManagedTenantOnboardingWizard` and the existing draft picker using current draft lifecycle, provider connection summary, verification assist, the existing 30-day permission freshness rule, and explicit "check has not run yet" guidance where no evidence exists.
|
||||||
|
- Keep landing-route behavior intact: a single resumable draft still redirects to the route-bound draft; a multi-draft landing view gains compact readiness snippets next to existing metadata.
|
||||||
|
- Define next-action precedence from existing truth only: identify tenant → connect provider → grant consent/fix permissions → run or rerun verification → review bootstrap → complete onboarding.
|
||||||
|
- Keep permission and consent detail secondary and provider-owned; keep `Open operation` and consent links canonical and shared.
|
||||||
|
- Keep onboarding-specific readiness wording and action prioritization local to the onboarding workflow; if a shared provider or verification builder needs extension, keep the change additive and regression-safe for existing non-onboarding consumers.
|
||||||
|
- Defer numeric completion score and any support-diagnostic or trial/demo-specific projection to later specs that reuse the same derived onboarding truth.
|
||||||
|
- Do not add new tables, readiness enums, global resources, or provider-generic onboarding abstractions.
|
||||||
|
|
||||||
|
## Phase 1 — Agent Context Update
|
||||||
|
|
||||||
|
After Phase 1 artifacts are generated, update Copilot context from the plan:
|
||||||
|
|
||||||
|
- `/Users/ahmeddarrazi/Documents/projects/wt-plattform/.specify/scripts/bash/update-agent-context.sh copilot`
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||||
|
|
||||||
|
- Extend onboarding landing and draft rendering to surface a derived readiness summary, freshness, and one primary next action from existing services/helpers.
|
||||||
|
- Reuse `OnboardingLifecycleService` snapshot truth, `OnboardingDraftStageResolver`, `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, and `TenantRequiredPermissionsViewModelBuilder` to map existing records into operator-facing copy.
|
||||||
|
- Add compact readiness metadata to the multi-draft picker and route-bound resume context without creating a new onboarding index resource.
|
||||||
|
- Preserve existing consent, verification, bootstrap, operation-link, and destructive header actions; only change prioritization, grouping emphasis, and explanatory copy.
|
||||||
|
- Expand targeted feature and policy tests for readiness states, freshness/mismatch handling, landing summary behavior, and 404 vs 403 regressions.
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS. Design artifacts keep readiness derived, DB-only at render time, workspace/tenant safe, Ops-UX compliant, and constrained to existing onboarding + provider foundations.
|
||||||
39
specs/240-tenant-onboarding-readiness/quickstart.md
Normal file
39
specs/240-tenant-onboarding-readiness/quickstart.md
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Quickstart — Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
## Prereqs
|
||||||
|
|
||||||
|
- Docker running
|
||||||
|
- Laravel Sail dependencies installed
|
||||||
|
- Current feature branch checked out: `240-tenant-onboarding-readiness`
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
- Start containers: `cd apps/platform && ./vendor/bin/sail up -d`
|
||||||
|
- Run targeted validation after implementation:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php`
|
||||||
|
- Format after implementation: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
|
||||||
|
## Manual smoke (after implementation)
|
||||||
|
|
||||||
|
1. Select a workspace and open `/admin/onboarding`.
|
||||||
|
2. With multiple resumable drafts, confirm each draft card shows current stage, readiness summary, freshness cue, and one primary next action.
|
||||||
|
3. Open a draft with missing consent and confirm the workflow points to consent or permission remediation instead of generic incomplete-state copy.
|
||||||
|
4. Open a draft with a changed provider connection or mismatched verification run and confirm readiness falls back to needs-attention with a canonical `Open operation` link when evidence exists.
|
||||||
|
5. Open the workflow as a wrong-workspace actor or actor without linked-tenant entitlement and confirm 404; open as a workspace member without onboarding capability and confirm 403 on protected actions.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Filament v5 requires Livewire v4.0+; this repo already satisfies that requirement.
|
||||||
|
- Laravel 11+ panel providers are registered in `bootstrap/providers.php`; this feature does not add or change panel providers.
|
||||||
|
- No new Filament Resource or Global Search surface is planned, so global search behavior is unchanged.
|
||||||
|
- No new assets are registered. Deployment keeps the existing Filament asset step (`cd apps/platform && php artisan filament:assets`) when other asset-bearing changes require it.
|
||||||
|
- Readiness freshness for this slice reuses existing signals only: connection-change / selected-connection mismatch from onboarding lifecycle state plus stored permission freshness from `TenantRequiredPermissionsViewModelBuilder`.
|
||||||
|
|
||||||
|
## Implementation proof — 2026-04-25
|
||||||
|
|
||||||
|
- Targeted fast-feedback validation passed: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php` — 55 tests, 212 assertions.
|
||||||
|
- Formatting passed: `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
||||||
|
- Guardrail close-out: keep. No provider-boundary drift, no lane-cost escalation, no new assets, no new persistence, and no follow-up spec required.
|
||||||
|
- Explicit defers retained: numeric completion score and cross-surface readiness reuse.
|
||||||
72
specs/240-tenant-onboarding-readiness/research.md
Normal file
72
specs/240-tenant-onboarding-readiness/research.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Research: Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
**Branch**: `240-tenant-onboarding-readiness`
|
||||||
|
**Date**: 2026-04-25
|
||||||
|
**Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/spec.md`
|
||||||
|
**Plan**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/240-tenant-onboarding-readiness/plan.md`
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### D-001 — Keep readiness derived inside the existing onboarding workflow
|
||||||
|
|
||||||
|
**Decision**: Compose onboarding readiness inside the existing `ManagedTenantOnboardingWizard` and related landing/draft-picker rendering, using current onboarding, provider connection, verification, and permission-posture truth. Do not add an onboarding-readiness table, persisted projection, or new readiness enum.
|
||||||
|
|
||||||
|
**Rationale**: The repo already stores the durable workflow truth in `TenantOnboardingSession`, `ProviderConnection`, `OperationRun`, and existing permission-posture data. `OnboardingLifecycleService` already computes checkpoint and action-required state, while `ProviderConnectionSurfaceSummary` and verification-assist builders already expose the provider and permissions detail needed for an operator-facing summary.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a dedicated onboarding-readiness model or table: rejected because the summary is derived and has no independent lifecycle.
|
||||||
|
- Introduce a reusable cross-provider onboarding framework: rejected because the current release has one provider and already has sufficient provider-owned seams.
|
||||||
|
|
||||||
|
### D-002 — Reuse the current landing route behavior instead of creating a new onboarding register
|
||||||
|
|
||||||
|
**Decision**: Keep `/admin/onboarding` as the workspace-scoped entry point that either redirects to the single resumable draft or renders the existing multi-draft picker. Add compact readiness snippets to that same picker rather than introducing a second onboarding dashboard or register.
|
||||||
|
|
||||||
|
**Rationale**: `ManagedTenantOnboardingWizard::resolveLandingState()` already treats the landing route and the route-bound draft as one workflow surface. `OnboardingDraftPickerTest` proves that the picker already carries stage, attribution, and resume/view actions. Adding readiness context there keeps the operator in the same workflow instead of splitting decision-making across multiple pages.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- New onboarding-list resource or canonical register: rejected because it would duplicate draft-selection semantics and enlarge scope.
|
||||||
|
- Landing-only summary without draft-picker enrichment: rejected because operators with multiple drafts still need draft-level readiness to choose the correct draft.
|
||||||
|
|
||||||
|
### D-003 — Reuse existing freshness signals; do not invent a new onboarding freshness policy
|
||||||
|
|
||||||
|
**Decision**: Treat readiness freshness as a composition of existing signals only:
|
||||||
|
- `OnboardingLifecycleService` connection-change and selected-connection mismatch signals (`connection_recently_updated`, `verification_result_stale`), and
|
||||||
|
- `TenantRequiredPermissionsViewModelBuilder::deriveFreshness()` for stored permission posture freshness (current repo rule: stale when absent or older than 30 days).
|
||||||
|
|
||||||
|
Do not introduce a second onboarding-specific freshness threshold, new config key, or persisted freshness state in this slice.
|
||||||
|
|
||||||
|
**Rationale**: The spec’s “freshness” requirement can be satisfied by existing repo truth without creating new semantics. The onboarding workflow already downgrades mismatched or changed verification evidence to action-required, while permission posture already carries a timestamp-based freshness rule used by verification-assist detail.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Add a new configurable verification-run age threshold for onboarding: rejected because there is no existing shared onboarding policy for it, and this slice should not create one.
|
||||||
|
- Persist freshness state on the onboarding draft: rejected because freshness is a derived presentation concern from existing evidence timestamps and mismatch flags.
|
||||||
|
|
||||||
|
### D-004 — Keep permission and consent diagnostics provider-owned and secondary
|
||||||
|
|
||||||
|
**Decision**: Use `ProviderConnectionSurfaceSummary`, `VerificationAssistViewModelBuilder`, and `TenantRequiredPermissionsViewModelBuilder` to expose provider-specific consent and permission detail as secondary diagnostics beneath a platform-neutral readiness summary.
|
||||||
|
|
||||||
|
**Rationale**: The top-level operator question is provider-neutral: “Is this tenant ready, and what should I do next?” The exact remediation details still need Microsoft-specific wording today, and those seams already exist in the repo.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Flatten Microsoft-specific permission names into a new platform-core readiness taxonomy: rejected because it would deepen provider coupling in shared UI semantics.
|
||||||
|
- Hide detailed permission context from onboarding entirely: rejected because the operator still needs precise remediation guidance without opening raw operation data first.
|
||||||
|
|
||||||
|
### D-005 — Preserve shared OperationRun link and start semantics
|
||||||
|
|
||||||
|
**Decision**: Any readiness CTA that opens evidence or reuses verification/bootstrap actions must stay on the current shared OperationRun and Ops-UX paths: `OperationRunLinks`, existing onboarding `tenantlessOperationRunUrl(...)`, `OperationUxPresenter`, and `ProviderOperationStartResultPresenter`.
|
||||||
|
|
||||||
|
**Rationale**: The workflow already starts verification as queued work and already links to canonical operation detail. This feature only changes explanation and action prioritization, not run creation semantics.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Create onboarding-specific run links or custom queued messaging: rejected because it would violate the shared OperationRun UX contract.
|
||||||
|
- Add a new readiness “refresh” operation type: rejected because the current verification and bootstrap actions already exist.
|
||||||
|
|
||||||
|
### D-006 — Prove the slice with fast-feedback onboarding and policy coverage only
|
||||||
|
|
||||||
|
**Decision**: Keep validation in fast-feedback by extending existing onboarding feature tests and policy/unit coverage. The primary proof set is `ManagedTenantOnboardingWizardTest`, `OnboardingDraftPickerTest`, `OnboardingDraftAuthorizationTest`, `TenantOnboardingSessionPolicyTest`, and `TenantRequiredPermissionsFreshnessTest`.
|
||||||
|
|
||||||
|
**Rationale**: The workflow is server-driven Filament/Livewire UI with existing fixtures. Feature tests can prove landing behavior, draft rendering, readiness wording, next-action precedence, and 404 vs 403 semantics without adding new browser or heavy-governance families.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- New browser smoke coverage as the primary proof: rejected because the feature is DB-driven and already covered by focused feature tests.
|
||||||
|
- Unit-only coverage for a new summary builder: rejected because the main risk is integrated workflow rendering and authorization semantics, not pure transformation logic.
|
||||||
297
specs/240-tenant-onboarding-readiness/spec.md
Normal file
297
specs/240-tenant-onboarding-readiness/spec.md
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
# Feature Specification: Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
**Feature Branch**: `[240-tenant-onboarding-readiness]`
|
||||||
|
**Created**: 2026-04-25
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Promote the roadmap-fit candidate 'Self-Service Tenant Onboarding & Connection Readiness' as a narrow, implementation-ready slice that turns the existing managed-tenant onboarding flow into an operator-facing readiness workflow. The slice should reuse existing onboarding session, provider connection, verification, and checkpoint foundations to show guided setup progress, provider connection health, permission and consent diagnostics, freshness, and concrete next actions before deeper governance workflows begin. It should explicitly reduce founder-led manual onboarding and make the current onboarding state understandable without raw run inspection. Out of scope: CRM/trial pipeline, billing, marketplace/provider expansion, autonomous remediation, or broad new onboarding persistence if existing onboarding/session/provider models are sufficient."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Managed-tenant onboarding truth is fragmented across onboarding drafts, provider connection status, verification runs, permission posture, and checkpoint progression, so an operator cannot quickly tell whether a tenant is genuinely ready without founder guidance or raw run inspection.
|
||||||
|
- **Today's failure**: Onboarding stalls behind generic failure or incomplete-state messaging, operators cannot reliably distinguish missing consent from missing permissions or stale verification, and founder-led walkthroughs fill the product gap.
|
||||||
|
- **User-visible improvement**: The existing onboarding workflow becomes a guided readiness view that shows setup progress, connection health, permission and consent blockers, freshness, and one concrete next action in the same place the operator resumes onboarding.
|
||||||
|
- **Smallest enterprise-capable version**: Reuse the current onboarding session, provider connection, verification, permission diagnostics, and checkpoint foundations to add one derived readiness summary inside the existing managed-tenant onboarding workflow, with canonical links to existing evidence and actions.
|
||||||
|
- **Explicit non-goals**: CRM or trial pipeline work, billing or entitlement changes, provider marketplace expansion, autonomous remediation, a second onboarding persistence model, a generalized multi-provider onboarding framework, or a numeric onboarding completion score for this slice.
|
||||||
|
- **Permanent complexity imported**: A bounded derived readiness composition for the existing onboarding surface, readiness-specific copy/state mapping on the current wizard, and focused feature tests for readiness, freshness, and authorization; no new persisted entity, capability family, or cross-domain framework.
|
||||||
|
- **Why now**: This is the first roadmap item in the self-service foundation cluster and directly removes founder-led onboarding work while making later support diagnostic and trial flows smaller and safer.
|
||||||
|
- **Why not local**: The pain spans onboarding checkpoint state, provider connection status, permission posture, verification freshness, and supporting evidence links; isolated copy changes in one step would preserve drift and false-green risk.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: 4 foundation-sounding theme. Defense: this slice is explicitly constrained to current onboarding routes and existing truth, with no new persistence or platform framework.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**: `/admin/onboarding`, `/admin/onboarding/{onboardingDraft}`, linked `/admin/consent/start` and `/admin/consent/callback` flows, and canonical `/admin/operations/{run}` deep links for supporting evidence
|
||||||
|
- **Data Ownership**: `TenantOnboardingSession` remains the workspace-owned workflow record; linked `ProviderConnection`, permission/verification truth, and `OperationRun` evidence remain existing tenant-owned or run-owned records; readiness stays derived and non-persistent
|
||||||
|
- **RBAC**: Workspace membership is mandatory. `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD` gates view/update of the readiness workflow, `Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_CANCEL` gates destructive cancellation, linked-tenant visibility still passes tenant-bound administrative viewability checks, non-members or wrong workspace/tenant scope return 404, and members lacking capability return 403.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: N/A - this slice stays in the workspace onboarding workflow, not a canonical cross-tenant register
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Existing workspace membership plus linked-tenant viewability checks continue to deny as not found before any readiness data or supporting operation detail is revealed
|
||||||
|
|
||||||
|
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||||
|
|
||||||
|
- **Cross-cutting feature?**: yes
|
||||||
|
- **Interaction class(es)**: status messaging, action links, embedded diagnostics, navigation
|
||||||
|
- **Systems touched**: managed-tenant onboarding workflow, onboarding lifecycle snapshot/stage resolution, provider connection summary rendering, permission diagnostics, canonical operation links
|
||||||
|
- **Existing pattern(s) to extend**: onboarding lifecycle snapshot, embedded verification-report and provider-connection summary patterns, canonical operation link helper
|
||||||
|
- **Shared contract / presenter / builder / renderer to reuse**: `App\Services\Onboarding\OnboardingLifecycleService`, `App\Services\Onboarding\OnboardingDraftStageResolver`, `App\Support\Providers\TargetScope\ProviderConnectionSurfaceSummary`, and `App\Support\OperationRunLinks`
|
||||||
|
- **Why the existing shared path is sufficient or insufficient**: These paths already compute checkpoint progression, verification state, bootstrap summaries, provider connection readiness wording, and canonical operation navigation. The slice only needs to compose them into one operator-facing readiness view.
|
||||||
|
- **Allowed deviation and why**: Provider-specific Microsoft permission names and consent details may remain inside provider-owned diagnostic detail because exact remediation still depends on the current provider.
|
||||||
|
- **Consistency impact**: Readiness labels, freshness cues, next-action verbs, and `Open operation`/consent link semantics must stay aligned across onboarding checkpoints and linked diagnostics.
|
||||||
|
- **Review focus**: Verify that no second readiness taxonomy, local status palette, or page-specific operation-link language appears outside the shared paths above, and that any shared builder change stays additive and behavior-preserving for non-onboarding consumers while onboarding-specific action prioritization remains page-local.
|
||||||
|
|
||||||
|
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||||
|
|
||||||
|
- **Touches OperationRun start/completion/link UX?**: yes
|
||||||
|
- **Shared OperationRun UX contract/layer reused**: `App\Support\OperationRunLinks` for canonical tenantless `Open operation` links, the existing onboarding `tenantlessOperationRunUrl(...)` helper, and `App\Services\OperationRunService` as the only owner of run status/outcome transitions for any reused verification/bootstrap actions
|
||||||
|
- **Delegated start/completion UX behaviors**: canonical `Open operation` link resolution, tenant/workspace-safe operation URL resolution, existing queued-intent behavior for reused verification starts, dedupe-or-blocked messaging for existing verification actions, and terminal lifecycle notifications through the current shared run UX path
|
||||||
|
- **Local surface-owned behavior that remains**: readiness explanation, freshness wording, and prioritization of the one next action inside the onboarding workflow
|
||||||
|
- **Queued DB-notification policy**: no new queued or running DB notification is introduced in this slice; any terminal notification remains explicit and central
|
||||||
|
- **Terminal notification path**: central lifecycle mechanism already used by the underlying run flow
|
||||||
|
- **Exception required?**: none
|
||||||
|
|
||||||
|
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||||
|
|
||||||
|
- **Shared provider/platform boundary touched?**: yes
|
||||||
|
- **Boundary classification**: mixed
|
||||||
|
- **Seams affected**: provider connection descriptors, consent and permission diagnostics, readiness copy, next-action labels, freshness messaging
|
||||||
|
- **Neutral platform terms preserved or introduced**: provider connection, readiness, diagnostics, next action, freshness, onboarding step
|
||||||
|
- **Provider-specific semantics retained and why**: Microsoft consent and permission names remain inside contextual diagnostic detail because the operator needs exact remediation instructions for the only supported provider.
|
||||||
|
- **Why this does not deepen provider coupling accidentally**: No new platform-core enum, persisted state, or canonical taxonomy becomes Microsoft-shaped. Top-level readiness stays a derived composition of existing onboarding and provider connection truth.
|
||||||
|
- **Follow-up path**: none
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
Use this section to classify UI and surface risk once. If the feature does
|
||||||
|
not change an operator-facing surface, write `N/A - no operator-facing surface
|
||||||
|
change` here and do not invent duplicate prose in the downstream surface tables.
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Managed tenant onboarding workflow | yes | Native Filament + shared primitives | status messaging, action links, badges, embedded diagnostics | page, workflow step, derived readiness summary | no | Guided-flow surface; no new route family |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing surface,
|
||||||
|
fill out one row per affected surface. This role is orthogonal to the
|
||||||
|
Action Surface Class / Surface Type below. Reuse the exact surface names
|
||||||
|
and classifications from the UI / Surface Guardrail Impact section above.
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Managed tenant onboarding workflow | Primary Decision Surface | Decide whether onboarding can proceed now or which blocker to resolve first | Current checkpoint, readiness summary, freshness, linked tenant/provider scope, and one primary next action | Full permission diff, provider-specific diagnostic detail, canonical operation detail | Primary because this is the place where the operator resumes or completes onboarding work, not a secondary diagnostic register | Follows identify → connect provider → verify access → bootstrap → activate progression inside one workflow context | Removes the need to open raw operation detail or separate provider screens just to understand what to do next |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds or materially changes an operator-facing list, detail, queue, audit, config, or report surface,
|
||||||
|
fill out one row per affected surface. Declare the broad Action Surface
|
||||||
|
Class first, then the detailed Surface Type. Keep this table in sync
|
||||||
|
with the Decision-First Surface Role section above and avoid renaming the
|
||||||
|
same surface a second time.
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Managed tenant onboarding workflow | Workflow / Guided action entry | Guided onboarding / readiness workflow | Resolve the current blocker or continue to the next checkpoint | In-page readiness section on the current draft route | forbidden | Supporting diagnostics and collection navigation stay secondary within section reveals or footer links | Header-only destructive actions, confirmation-protected | `/admin/onboarding` | `/admin/onboarding/{onboardingDraft}` | Workspace context, linked tenant identity, provider connection summary | Onboarding / Onboarding readiness | Current checkpoint, connection health, permission/consent blocker, freshness, and next action | Guided-workflow exception; this is a wizard surface, not a CRUD resource, but it still keeps one primary decision context |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
If this feature adds a new operator-facing page or materially refactors
|
||||||
|
one, fill out one row per affected page/surface. The contract MUST show
|
||||||
|
how one governance case or operator task becomes decidable without
|
||||||
|
unnecessary cross-page reconstruction.
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Managed tenant onboarding workflow | Workspace operator managing tenant setup | Decide whether onboarding can proceed and execute the next readiness action | Guided workflow | What is blocking this tenant from being ready, and what do I do next? | Checkpoint progress, readiness summary, provider connection health, consent/permission status, freshness of latest evidence, and the primary next action | Full permission diff, provider-specific identifiers, raw verification payloads, and canonical operation detail | workflow checkpoint, readiness outcome, freshness, execution outcome | TenantPilot only for draft progression; Microsoft tenant only when operator follows existing consent or verification actions | Continue onboarding, grant or regrant consent when needed, run or rerun verification when needed, open operation | Cancel onboarding, delete draft |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
Fill this section if the feature introduces any of the following:
|
||||||
|
- a new source of truth
|
||||||
|
- a new persisted entity, table, or artifact
|
||||||
|
- a new abstraction (interface, contract, registry, resolver, strategy, factory, orchestration layer)
|
||||||
|
- a new enum, status family, reason code family, or lifecycle category
|
||||||
|
- a new cross-domain UI framework, taxonomy, or classification system
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Operators cannot trust or explain onboarding readiness from the current workflow without opening raw diagnostics or asking the founder.
|
||||||
|
- **Existing structure is insufficient because**: The current truth exists, but it is spread across workflow state, provider connection state, verification evidence, and permission posture with no single operator-facing readiness composition.
|
||||||
|
- **Narrowest correct implementation**: Compose existing onboarding lifecycle, provider connection summary, permission diagnostics, and operation evidence inside the current onboarding workflow, with no new persistence or platform-wide framework.
|
||||||
|
- **Ownership cost**: A small amount of readiness mapping and focused feature-test coverage must be maintained, but there is no new stored truth, capability family, or cross-domain taxonomy.
|
||||||
|
- **Alternative intentionally rejected**: A new onboarding-readiness table/state machine or generic provider-onboarding framework was rejected as overproduction for the current release.
|
||||||
|
- **Release truth**: current-release truth
|
||||||
|
|
||||||
|
### Compatibility posture
|
||||||
|
|
||||||
|
This feature assumes a pre-production environment.
|
||||||
|
|
||||||
|
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||||
|
|
||||||
|
Canonical replacement is preferred over preservation.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
For docs-only or template-only changes, state concise `N/A` or `none`. For runtime- or test-affecting work, classification MUST follow the proving purpose of the change rather than the file path or folder name.
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature, Unit
|
||||||
|
- **Validation lane(s)**: fast-feedback
|
||||||
|
- **Why this classification and these lanes are sufficient**: Livewire/Filament feature tests prove readiness mapping, next-action guidance, retained action behavior, freshness truth, and deny semantics on the onboarding surface, while targeted unit and policy coverage proves reused permission-freshness and authorization seams without browser automation or heavy-governance breadth.
|
||||||
|
- **New or expanded test families**: extend onboarding feature coverage plus targeted unit and policy coverage for onboarding authorization and reused permission-freshness behavior only
|
||||||
|
- **Fixture / helper cost impact**: workspace membership, onboarding draft, linked tenant, provider connection, and operation-run states are required; avoid new browser harnesses, new heavy fixtures, or provider mocks beyond the current readiness scenarios
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament
|
||||||
|
- **Standard-native relief or required special coverage**: ordinary onboarding feature coverage plus focused policy and freshness regressions only
|
||||||
|
- **Reviewer handoff**: Confirm that negative authorization still distinguishes 404 vs 403 correctly, retained start and destructive actions keep their existing guardrails, no browser-only proof was added for a server-driven workflow, and proof commands remain limited to onboarding, onboarding-authorization, and freshness-policy coverage.
|
||||||
|
- **Budget / baseline / trend impact**: none expected beyond ordinary feature-local runtime
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**: `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingDraftPickerTest.php tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php`; `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Resume Onboarding With Trustworthy Readiness (Priority: P1)
|
||||||
|
|
||||||
|
An authorized workspace operator opens an onboarding draft and immediately sees the current setup step, whether the tenant is actually ready, what evidence is fresh or stale, and what the next action is without needing to inspect raw operations first.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest slice that directly removes founder-led manual walkthroughs and turns the current onboarding state into a self-explanatory workflow.
|
||||||
|
|
||||||
|
**Independent Test**: Seed onboarding drafts in multiple lifecycle states with linked provider connections and verification outcomes, open the onboarding routes, and confirm the workflow shows checkpoint progress, readiness, freshness, and one primary next action.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an authorized operator opens an existing onboarding draft with a linked provider connection, **When** the workflow loads, **Then** it shows the current checkpoint, readiness summary, freshness, and one primary next action without requiring raw run inspection.
|
||||||
|
2. **Given** an operator opens the onboarding landing page while resumable drafts already exist, **When** the landing context renders, **Then** it shows enough progress and blocker context to choose the correct draft to resume.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Diagnose Consent And Permission Blockers (Priority: P2)
|
||||||
|
|
||||||
|
An authorized operator can tell whether onboarding is blocked by missing consent, missing permissions, disabled or unhealthy provider connection state, or an unresolved verification result, and receives explicit guidance for the next corrective action.
|
||||||
|
|
||||||
|
**Why this priority**: Trustworthy blocker classification is the part that prevents false-green readiness and generic error handling from turning into support work.
|
||||||
|
|
||||||
|
**Independent Test**: Seed drafts with missing consent, permission gaps, disabled connections, and blocked verification outcomes, then confirm each scenario shows distinct blocker language and the appropriate next action.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** consent is missing or revoked, **When** the operator opens the readiness workflow, **Then** the blocker is labeled explicitly and the primary action points to the consent follow-up flow rather than generic failure copy.
|
||||||
|
2. **Given** required permissions are missing or insufficient, **When** the operator opens the readiness workflow, **Then** the operator sees explicit permission diagnostics in provider-owned terms and a concrete remediation path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Trust Freshness And Supporting Evidence (Priority: P3)
|
||||||
|
|
||||||
|
An authorized operator can tell whether supporting verification or bootstrap evidence is current enough to trust, and can open the canonical supporting operation when more detail is necessary.
|
||||||
|
|
||||||
|
**Why this priority**: Freshness and evidence linkage prevent the workflow from becoming a false-ready facade while still keeping raw diagnostics secondary.
|
||||||
|
|
||||||
|
**Independent Test**: Seed matching, stale, and mismatched verification runs for the same draft, then confirm the workflow surfaces freshness truth and a single canonical operation link only when evidence exists.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the latest verification run is stale or tied to a different selected provider connection, **When** the operator opens the workflow, **Then** readiness falls back to a non-ready state with explicit freshness or mismatch guidance instead of reporting ready.
|
||||||
|
2. **Given** a supporting verification or bootstrap run exists, **When** the operator wants more detail, **Then** the workflow offers one canonical `Open operation` link that respects authorization.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- An onboarding draft exists before any tenant is linked; the workflow must show identity and connection prerequisites without implying the tenant is ready.
|
||||||
|
- The latest verification run belongs to a different provider connection than the currently selected one; the workflow must treat it as mismatched evidence and require attention.
|
||||||
|
- A provider connection was previously healthy but is now disabled or its consent was revoked; the workflow must not keep showing the older ready state.
|
||||||
|
- Health or verification evidence is older than the accepted freshness window; the workflow must show stale evidence and a rerun or refresh next action.
|
||||||
|
- An actor still has workspace membership but no longer has linked-tenant entitlement; the workflow must return 404 rather than partial readiness data.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature does not introduce a new Microsoft Graph domain, a new tenant-changing workflow, or a new queued-work family. It reuses existing onboarding, consent, verification, and bootstrap actions. Any reused verification or bootstrap action continues to rely on the current contract-registry-backed provider execution, existing safety gates, tenant isolation, `OperationRun` observability, and onboarding audit events.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The slice is derived-only. It must not add a new persisted readiness model, a new cross-provider onboarding abstraction, or a new canonical readiness state family. Existing onboarding lifecycle, provider consent/verification states, and supporting evidence remain the source of truth.
|
||||||
|
|
||||||
|
**Constitution alignment (XCUT-001):** The slice extends existing onboarding lifecycle composition, provider connection summary rendering, and canonical operation-link paths. No parallel onboarding health language or page-local operation-link system may appear.
|
||||||
|
|
||||||
|
**Constitution alignment (PROV-001):** Top-level readiness language stays platform-neutral, while Microsoft-specific consent and permission details remain contextual and provider-owned. The feature must not turn Microsoft permission or consent semantics into new platform-core truth.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Implementation must stay in fast-feedback feature coverage, keep fixtures opt-in and onboarding-local, and avoid creating a new heavy-governance or browser family for a server-driven readiness workflow.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** If the workflow exposes existing verification or bootstrap start actions, those actions must continue to use the existing queued-intent feedback contract, canonical `Open operation` links, central `OperationRunService` status ownership, and no new queued or running DB notifications.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX-START-001):** Any `OperationRun` deep link or reused start action in this workflow must delegate URL resolution and run-link behavior to the shared operation-link path rather than page-local URLs.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** Non-members, wrong-workspace actors, and actors lacking linked-tenant entitlement continue to receive 404. Members missing onboarding capability receive 403 on protected workflow actions. Existing destructive onboarding actions remain confirmation-protected and server-authorized.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Consent and verification badges remain driven by the central badge domains already used by provider connection summaries; no page-local color or label mapping is introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** The workflow continues to use native Filament sections, cards, badges, and shared primitives. The slice may reorganize emphasis, but it must not replace the workflow with custom fake-native controls.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** Primary operator-facing labels remain grounded in onboarding vocabulary such as `Continue onboarding`, `Grant consent`, `Run verification`, `Open operation`, `Cancel onboarding`, and `Delete draft`. Implementation-first language stays in secondary diagnostics only.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** The onboarding workflow remains the one primary decision surface for onboarding readiness. It must present enough truth to decide the next action without forcing cross-page reconstruction from raw operations.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** This guided workflow remains a bounded exception to list/detail table rules, but it must still keep one primary next action, preserve header-action discipline, keep destructive actions separated by risk, and avoid mixed catch-all action groups.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** Navigation, mutation, and destructive onboarding actions remain visibly separated. Secondary diagnostics do not compete with the primary next action.
|
||||||
|
|
||||||
|
**Constitution alignment (OPSURF-001):** The default-visible content must stay operator-first, with raw provider detail and full operation diagnostics deliberately secondary. Mutation scope remains explicit when the workflow points to provider actions.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Any new readiness wording remains a thin adapter over existing onboarding, provider connection, and verification truth. Tests must prove business consequences such as blocker classification, freshness, and authorization rather than a thin presentation layer alone.
|
||||||
|
|
||||||
|
**Constitution alignment (Filament Action Surfaces):** The managed-tenant onboarding page remains an explicit guided-workflow exception rather than a CRUD resource. Action inventory must still preserve one primary next action, avoid redundant inspect affordances, and keep destructive actions gated and confirmation-protected.
|
||||||
|
|
||||||
|
**Constitution alignment (UX-001 — Layout & Information Architecture):** The onboarding page continues to use native sections and cards, puts default-visible readiness truth ahead of diagnostics, and keeps empty/start states focused on one clear CTA.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST compose onboarding readiness from the existing onboarding session, provider connection state, verification evidence, permission diagnostics, and checkpoint progression instead of introducing a second onboarding-readiness persistence model.
|
||||||
|
- **FR-002**: The system MUST show the current checkpoint, completed progress, derived readiness summary, freshness, and one primary next action on the existing onboarding landing surfaces: the route-bound draft at `/admin/onboarding/{onboardingDraft}`, and the `/admin/onboarding` landing experience when multiple resumable drafts exist. If `/admin/onboarding` resolves directly to a single resumable draft, the existing redirect behavior remains in place.
|
||||||
|
- **FR-003**: The workflow MUST distinguish operator-visible conditions for not started, in progress, blocked, needs attention, stale evidence, and ready-to-proceed scenarios instead of collapsing them into generic incomplete-state copy.
|
||||||
|
- **FR-004**: When a linked provider connection exists, the workflow MUST reuse the existing provider connection summary path to show consent state, verification state, contextual identity details, and readiness wording.
|
||||||
|
- **FR-005**: Missing or insufficient permissions MUST produce explicit provider-owned diagnostics and a concrete next action, while top-level readiness vocabulary remains platform-neutral.
|
||||||
|
- **FR-006**: The workflow MUST display freshness for the latest verification or health evidence using the existing permission freshness threshold (currently 30 days) plus selected-provider-connection mismatch signals, and MUST mark stale or mismatched evidence as needing attention rather than ready.
|
||||||
|
- **FR-007**: Supporting verification or bootstrap evidence MUST deep-link through the canonical tenantless operation route and MUST expose only one primary `Open operation` affordance per supporting record.
|
||||||
|
- **FR-008**: If the workflow exposes existing start or rerun actions for consent, verification, or bootstrap follow-up, those actions MUST preserve existing authorization, audit, dedupe, and shared Ops-UX behavior and MUST NOT create new run-start paths.
|
||||||
|
- **FR-009**: Non-members, wrong-workspace actors, and actors without linked-tenant entitlement MUST receive 404 for readiness routes and supporting evidence routes; entitled members lacking the required onboarding capability MUST receive 403 on protected actions.
|
||||||
|
- **FR-010**: Existing destructive onboarding actions retained in the workflow, including cancel or delete draft behavior, MUST remain confirmation-protected and capability-gated, and this slice MUST NOT introduce new destructive behavior.
|
||||||
|
- **FR-011**: The workflow MUST reuse existing onboarding lifecycle and verification truth and MUST NOT introduce a new persisted readiness enum, new onboarding table, or generalized provider-onboarding framework.
|
||||||
|
- **FR-012**: The readiness outcome and next-action summary MUST remain structured as derived onboarding truth so later support diagnostic and trial/demo work can consume the same underlying composition, but cross-surface reuse beyond onboarding is explicitly deferred from this slice and MUST NOT introduce a new shared abstraction or second source of truth now.
|
||||||
|
- **FR-013**: Microsoft-specific consent and permission details MUST stay contextual and secondary to the operator-first readiness summary.
|
||||||
|
- **FR-014**: When no supporting verification or diagnostic evidence exists yet, the workflow MUST state that the check has not run yet and point to the correct next action rather than exposing empty diagnostics.
|
||||||
|
- **FR-015**: The workflow MUST continue to use native Filament sections, cards, and shared badge semantics instead of page-local status cards or custom color logic.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
|
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Managed tenant onboarding workflow | `app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` | Existing `Start onboarding` and draft-selection affordances remain; readiness-specific header additions stay non-destructive | N/A - guided workflow page, not a list table | Draft-picker context may expose `Resume onboarding`; readiness panels expose one primary `Open operation` link when evidence exists | none | `Start onboarding` on empty/start state | Existing `Cancel onboarding` and `Delete draft` remain in header placements by risk; `Open operation` and `Open tenant` stay contextual in-section links when legitimate | Existing wizard next/back/save/cancel behavior remains | yes for existing draft lifecycle mutations and reused start actions | Guided-workflow exception. Action Surface Contract remains satisfied through one primary next action in-page; destructive actions stay confirmation-protected and capability-gated. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Tenant Onboarding Session**: The existing workspace-scoped workflow record that tracks onboarding checkpoint progression, resumability, and optional linkage to a tenant.
|
||||||
|
- **Provider Connection**: The existing tenant-owned provider access record whose consent state, verification state, target-scope summary, and readiness wording inform onboarding readiness.
|
||||||
|
- **Onboarding Readiness Summary**: A derived, non-persistent operator view composed from onboarding lifecycle, provider connection truth, permission diagnostics, freshness, supporting operation evidence, and the next recommended action.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In each in-scope onboarding scenario, an authorized operator can identify the current blocker and next action from the onboarding workflow in 30 seconds or less without opening raw operation detail first.
|
||||||
|
- **SC-002**: In scripted scenarios covering missing consent, permission gaps, disabled or unhealthy provider connection state, stale verification, verification blocked, and ready-for-activation, 100% of rendered states show distinct operator-readable outcomes rather than generic incomplete-state messaging.
|
||||||
|
- **SC-003**: When supporting verification or bootstrap evidence exists, the operator can reach the canonical supporting operation from the onboarding workflow in one interaction.
|
||||||
|
- **SC-004**: In negative authorization scenarios for wrong workspace, non-member, or non-entitled linked-tenant access, 100% of requests hide onboarding readiness data behind 404 responses, while capability-denied members receive 403 on protected actions.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing onboarding session, provider connection, verification, and permission-posture foundations are sufficient for the first readiness slice.
|
||||||
|
- The current provider remains Microsoft-first, but provider-specific details stay contextual rather than becoming new platform-core truth.
|
||||||
|
- Later support diagnostic and trial/demo flows are expected to consume the same derived readiness truth, but any reuse beyond onboarding is deferred from this slice and must not force a new abstraction or second onboarding state model now.
|
||||||
194
specs/240-tenant-onboarding-readiness/tasks.md
Normal file
194
specs/240-tenant-onboarding-readiness/tasks.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list for feature implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: Self-Service Tenant Onboarding & Connection Readiness
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/240-tenant-onboarding-readiness/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/, quickstart.md, checklists/requirements.md
|
||||||
|
|
||||||
|
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the `fast-feedback` lane using the onboarding, authorization, policy, and freshness suites listed in `specs/240-tenant-onboarding-readiness/quickstart.md`.
|
||||||
|
|
||||||
|
## Test Governance Notes
|
||||||
|
|
||||||
|
- Lane assignment: `fast-feedback` is the narrowest sufficient proof for this slice.
|
||||||
|
- No new browser or heavy-governance family should be introduced; keep any new fixtures onboarding-local and cheap by default.
|
||||||
|
- Surface profile: `standard-native-filament` relief applies unless implementation widens the existing wizard/picker surface beyond the current plan.
|
||||||
|
- If implementation leaves a bounded provider-boundary hotspot or widens lane cost, record that outcome in `specs/240-tenant-onboarding-readiness/plan.md` and `specs/240-tenant-onboarding-readiness/quickstart.md` before merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Confirm the current onboarding baseline and pin the feature inputs before runtime edits begin.
|
||||||
|
|
||||||
|
- [X] T001 Run the baseline fast-feedback onboarding suites listed for this feature in specs/240-tenant-onboarding-readiness/quickstart.md (specs/240-tenant-onboarding-readiness/quickstart.md, apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php, apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php, apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php)
|
||||||
|
- [X] T002 [P] Review the derived-only readiness contract, next-action precedence, and provider-boundary constraints before editing runtime files (specs/240-tenant-onboarding-readiness/spec.md, specs/240-tenant-onboarding-readiness/plan.md, specs/240-tenant-onboarding-readiness/research.md, specs/240-tenant-onboarding-readiness/data-model.md, specs/240-tenant-onboarding-readiness/contracts/onboarding-readiness.openapi.yaml)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Establish the shared derived-readiness seams that every story depends on without adding persistence, enums, or a new onboarding framework.
|
||||||
|
|
||||||
|
**Critical**: No user story work should start until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T003 Create a presentation-only readiness payload shell on the existing onboarding page without adding new persistence or readiness enums (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T004 [P] Thread route-bound authorization and canonical operation-link seams through that readiness payload so later story work stays inside existing workspace and tenant boundaries (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php, apps/platform/app/Support/OperationRunLinks.php)
|
||||||
|
- [X] T005 [P] Reuse the existing provider summary, verification assist, and permission freshness builders as the only readiness inputs so provider-specific detail stays secondary and contextual, and keep any shared-builder extension additive and behavior-preserving for non-onboarding consumers (apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php, apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php)
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready - the onboarding wizard has one derived-readiness seam and later stories can extend it without creating a second source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Resume Onboarding With Trustworthy Readiness (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: An authorized workspace operator can open onboarding and immediately understand checkpoint progress, current readiness, freshness, and the one next action on both the landing route and the route-bound draft.
|
||||||
|
|
||||||
|
**Independent Test**: Seed resumable drafts in different lifecycle states, open `/admin/onboarding` and `/admin/onboarding/{onboardingDraft}`, and confirm the workflow shows progress, readiness, freshness, and one primary next action without raw operation inspection.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [X] T006 [P] [US1] Add route-bound readiness feature coverage for checkpoint progress, readiness summary, freshness cues, explicit "check has not run yet" guidance, and one primary next action in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||||
|
- [X] T007 [P] [US1] Add landing-route feature coverage for compact readiness snippets on multiple drafts while preserving the existing single-draft redirect behavior in apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php)
|
||||||
|
- [X] T008 [P] [US1] Extend readiness-route authorization coverage so non-members and wrong-workspace actors stay 404 while capability-denied members stay 403 in apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php)
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T009 [US1] Render the derived readiness summary, checkpoint progress, freshness note, explicit "check has not run yet" guidance, and primary next-action slot on the route-bound onboarding draft view in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T010 [US1] Surface compact readiness context on the multi-draft picker without changing the landing route contract, resume flow, or view-summary affordances in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T011 [US1] Keep draft and landing progress labels aligned through the shared lifecycle and stage resolvers instead of page-local state maps in apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php and apps/platform/app/Services/Onboarding/OnboardingDraftStageResolver.php (apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php, apps/platform/app/Services/Onboarding/OnboardingDraftStageResolver.php)
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently functional when operators can choose or resume the correct draft from readiness context alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Diagnose Consent And Permission Blockers (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: An authorized operator can tell whether onboarding is blocked by consent, permissions, or unhealthy provider connection state, and gets one concrete remediation path without introducing provider-specific top-level taxonomy.
|
||||||
|
|
||||||
|
**Independent Test**: Seed drafts with missing consent, revoked consent, permission gaps, and disabled connection states, then confirm each scenario renders distinct blocker language and the correct next action.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [X] T012 [P] [US2] Add blocker-matrix feature coverage for missing consent, revoked consent, disabled connection, and blocked verification scenarios in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||||
|
- [X] T013 [P] [US2] Add feature coverage proving permission-gap diagnostics stay provider-owned while the top-level readiness summary stays platform-neutral in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||||
|
- [X] T014 [P] [US2] Extend onboarding capability and linked-tenant entitlement policy coverage used by readiness blocker actions in apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php (apps/platform/tests/Unit/Policies/TenantOnboardingSessionPolicyTest.php)
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T015 [US2] Reuse provider connection summary state to classify consent and connection-health blockers without page-local badge or color mappings, and preserve current non-onboarding summary behavior unless an additive onboarding field is strictly required, in apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T016 [US2] Reuse verification-assist and required-permissions builders to expose provider-owned permission diagnostics, blocked-verification guidance, and remediation links inside the onboarding workflow, keeping onboarding-specific action prioritization local and preserving existing non-onboarding outputs unless an additive field is strictly required, in apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php, and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php, apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T017 [US2] Enforce next-action precedence for consent, permission, and blocked-verification blockers while keeping top-level readiness wording provider-neutral in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 2 is independently functional when blocker classification is explicit and remediation guidance is concrete without leaking Microsoft-specific terms into the top-level readiness contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Trust Freshness And Supporting Evidence (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: An authorized operator can tell whether verification evidence is fresh enough to trust and can open the canonical supporting operation when deeper detail is required.
|
||||||
|
|
||||||
|
**Independent Test**: Seed matching, stale, and mismatched verification evidence for the same draft, then confirm readiness downgrades appropriately and only canonical `Open operation` links are rendered when evidence exists.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [X] T018 [P] [US3] Add feature coverage for stale evidence, selected-connection mismatch, and canonical `Open operation` link rendering on the route-bound draft in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||||
|
- [X] T019 [P] [US3] Add landing-picker feature coverage for stale or mismatched freshness cues across multiple drafts in apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php (apps/platform/tests/Feature/Onboarding/OnboardingDraftPickerTest.php)
|
||||||
|
- [X] T020 [P] [US3] Extend freshness boundary coverage for missing and existing 30-day permission-threshold edge refresh timestamps used by readiness in apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php (apps/platform/tests/Unit/TenantRequiredPermissionsFreshnessTest.php)
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T021 [US3] Reuse connection-change and selected-connection-mismatch signals to downgrade readiness and surface freshness guidance on the onboarding page in apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T022 [US3] Route supporting verification and bootstrap evidence through canonical tenantless operation links only in apps/platform/app/Support/OperationRunLinks.php and apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Support/OperationRunLinks.php, apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
- [X] T023 [US3] Surface stale and mismatched evidence state on both the route-bound draft and the landing picker without adding persisted readiness flags in apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php (apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php)
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 3 is independently functional when stale or mismatched evidence prevents false-ready UI and canonical evidence links remain available for legitimate detail views.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final validation, formatting, and feature-local close-out for this readiness slice.
|
||||||
|
|
||||||
|
- [X] T024 [P] Add retained-action regression coverage proving reused consent, verification, and bootstrap start or rerun affordances still preserve shared Ops-UX, dedupe, canonical `Open operation` links, and audit behavior in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php)
|
||||||
|
- [X] T025 [P] Add retained destructive-action regression coverage proving cancel or delete draft affordances remain confirmation-protected and capability-gated in apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php and apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php (apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php, apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php)
|
||||||
|
- [X] T026 [P] Run Laravel Pint on touched PHP files through Sail before merge (apps/platform/vendor/bin/sail)
|
||||||
|
- [X] T027 Run the targeted fast-feedback validation suite listed in specs/240-tenant-onboarding-readiness/quickstart.md after implementation is complete (specs/240-tenant-onboarding-readiness/quickstart.md)
|
||||||
|
- [X] T028 Record the final guardrail close-out, fast-feedback proof result, and any `document-in-feature` versus `follow-up-spec` decision for remaining provider-boundary or lane-cost notes in specs/240-tenant-onboarding-readiness/plan.md and specs/240-tenant-onboarding-readiness/quickstart.md, including the explicit defer of numeric completion score and cross-surface readiness reuse (specs/240-tenant-onboarding-readiness/plan.md, specs/240-tenant-onboarding-readiness/quickstart.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- Phase 1 (Setup) starts immediately.
|
||||||
|
- Phase 2 (Foundational) depends on Phase 1 and blocks all user stories.
|
||||||
|
- Phase 3 (US1) depends on Phase 2 and establishes the MVP readiness surface.
|
||||||
|
- Phase 4 (US2) depends on Phase 2 and should follow US1 in practice because both stories change the same wizard and wizard test suite.
|
||||||
|
- Phase 5 (US3) depends on Phase 2 and is safest after US1 because freshness and evidence rendering extend the same readiness payload.
|
||||||
|
- Phase 6 (Polish) depends on every implemented story.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- US1 is the MVP and the first independently shippable increment.
|
||||||
|
- US2 reuses the US1 readiness surface but remains independently testable through blocker-specific scenarios.
|
||||||
|
- US3 reuses the same readiness surface and remains independently testable through freshness and evidence scenarios.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the listed Pest coverage first and ensure it fails before implementation.
|
||||||
|
- Complete service or builder changes before the final wizard rendering pass when both are required.
|
||||||
|
- Re-run the narrowest affected fast-feedback suite after each story checkpoint before moving to the next story.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- T001 and T002 can run in parallel if one person captures the baseline while another cross-checks the feature artifacts.
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- T004 and T005 can run in parallel after T003 defines the readiness payload shell.
|
||||||
|
|
||||||
|
### User Story 1
|
||||||
|
|
||||||
|
- T006, T007, and T008 can run in parallel.
|
||||||
|
- T009 and T011 can overlap once the shared readiness payload from Phase 2 is in place.
|
||||||
|
|
||||||
|
### User Story 2
|
||||||
|
|
||||||
|
- T012, T013, and T014 can run in parallel.
|
||||||
|
- T015 and T016 can overlap before T017 finalizes next-action precedence.
|
||||||
|
|
||||||
|
### User Story 3
|
||||||
|
|
||||||
|
- T018, T019, and T020 can run in parallel.
|
||||||
|
- T021 and T022 can overlap before T023 finalizes the shared stale-evidence presentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1.
|
||||||
|
2. Complete Phase 2.
|
||||||
|
3. Complete Phase 3 (US1).
|
||||||
|
4. Re-run the targeted US1 fast-feedback suites and stop for review.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Deliver US1 to make onboarding state understandable on landing and draft routes.
|
||||||
|
2. Add US2 to classify consent and permission blockers without broadening the provider boundary.
|
||||||
|
3. Add US3 to harden freshness and evidence trust so the readiness surface cannot go false-green.
|
||||||
|
|
||||||
|
### Team Strategy
|
||||||
|
|
||||||
|
1. Finish Phase 2 together before splitting work.
|
||||||
|
2. Parallelize test authoring inside each story.
|
||||||
|
3. Sequence wizard-file merges story-by-story because all three stories converge on apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php and its onboarding feature suites.
|
||||||
Loading…
Reference in New Issue
Block a user