From 1655cc481e1f1c614d37a182e3e6daae65ec8ad9 Mon Sep 17 00:00:00 2001 From: ahmido Date: Fri, 10 Apr 2026 11:22:56 +0000 Subject: [PATCH] Spec 188: canonical provider connection state cleanup (#219) ## Summary - migrate provider connections to the canonical three-dimension state model: lifecycle via `is_enabled`, consent via `consent_status`, and verification via `verification_status` - remove legacy provider status and health badge paths, update admin and system directory surfaces, and align onboarding, consent callback, verification, resolver, and mutation flows with the new model - add the Spec 188 artifact set, schema migrations, guard coverage, and expanded provider-state tests across admin, system, onboarding, verification, and rendering paths ## Verification - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/SystemPanelAuthTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php` - integrated browser smoke: validated admin provider list/detail/edit, tenant provider summary, system directory tenant detail, provider-connection search exclusion, and cleaned up the temporary smoke record afterward ## Filament / implementation notes - Livewire v4.0+ compliance: preserved; this change targets Filament v5 on Livewire v4 and does not introduce older APIs - Provider registration location: unchanged; Laravel 11+ panel providers remain registered in `bootstrap/providers.php` - Globally searchable resources: `ProviderConnectionResource` remains intentionally excluded from global search; tenant global search remains enabled and continues to resolve to view pages - Destructive actions: no new destructive action surface was introduced without confirmation or authorization; existing capability checks continue to gate provider mutations - Asset strategy: unchanged; no new Filament assets were added, so deploy behavior for `php artisan filament:assets` remains unchanged - Testing plan covered: system auth, tenant global search, provider lifecycle enable/disable behavior, and provider truth cleanup cutover behavior Co-authored-by: Ahmed Darrazi Reviewed-on: https://git.cloudarix.de/ahmido/TenantAtlas/pulls/219 --- .github/agents/copilot-instructions.md | 4 +- .../Commands/ClassifyProviderConnections.php | 15 +- .../ManagedTenantOnboardingWizard.php | 10 +- .../Resources/ProviderConnectionResource.php | 118 ++--- .../Pages/CreateProviderConnection.php | 10 +- .../Pages/EditProviderConnection.php | 42 +- .../app/Filament/Resources/TenantResource.php | 14 +- .../System/Pages/Directory/Tenants.php | 17 +- .../System/Pages/Directory/ViewTenant.php | 12 +- .../AdminConsentCallbackController.php | 15 +- .../TenantOnboardingController.php | 10 +- .../Jobs/ProviderConnectionHealthCheckJob.php | 5 +- .../app/Jobs/ScanEntraAdminRolesJob.php | 6 +- .../app/Models/ProviderConnection.php | 20 +- .../Providers/Contracts/HealthResult.php | 14 +- .../MicrosoftProviderHealthCheck.php | 31 +- .../ProviderConnectionClassifier.php | 2 +- .../ProviderConnectionMutationService.php | 66 +-- .../Providers/ProviderConnectionResolver.php | 49 +- .../ProviderConnectionStateProjector.php | 112 +---- .../Verification/StartVerification.php | 11 - .../app/Support/Badges/BadgeCatalog.php | 25 - .../app/Support/Badges/BadgeDomain.php | 2 - .../Domains/ProviderConnectionHealthBadge.php | 23 - .../Domains/ProviderConnectionStatusBadge.php | 23 - .../Support/System/SystemDirectoryLinks.php | 21 +- .../factories/ProviderConnectionFactory.php | 15 +- ...add_is_enabled_to_provider_connections.php | 27 ++ ...tate_columns_from_provider_connections.php | 31 ++ .../provider-connection-state.blade.php | 34 +- .../pages/directory/view-tenant.blade.php | 29 +- .../Browser/OnboardingDraftRefreshTest.php | 6 +- .../OnboardingDraftVerificationResumeTest.php | 10 +- ...ec172DeferredOperatorSurfacesSmokeTest.php | 6 +- .../Feature/AdminConsentCallbackTest.php | 16 +- .../Audit/OnboardingDraftAuditTest.php | 2 +- ...erConnectionConsentRevocationAuditTest.php | 9 +- ...roviderConnectionVerificationAuditTest.php | 12 +- .../ScanEntraAdminRolesJobTest.php | 50 ++ .../ProviderConnectionsDbOnlyTest.php | 6 +- .../ProviderConnectionsUiEnforcementTest.php | 4 +- .../SettingsCatalogPolicySyncTest.php | 2 +- .../Filament/SettingsCatalogRestoreTest.php | 2 +- .../Feature/Filament/TenantSetupTest.php | 4 +- .../TenantTruthCleanupSpec179Test.php | 22 +- .../TenantVerificationReportWidgetTest.php | 6 +- .../WindowsUpdateProfilesRestoreTest.php | 2 +- .../Filament/WindowsUpdateRingRestoreTest.php | 2 +- .../Guards/ActionSurfaceContractTest.php | 2 +- ...acyProviderConnectionStateFallbackTest.php | 51 +++ .../Inventory/InventorySyncServiceTest.php | 2 +- .../ManagedTenantOnboardingWizardTest.php | 2 +- ...nConsentCallbackPlatformConnectionTest.php | 4 +- .../Onboarding/OnboardingActivationTest.php | 6 +- .../OnboardingDraftAuthorizationTest.php | 4 +- .../OnboardingDraftLifecycleTest.php | 2 +- ...gProviderConnectionPlatformDefaultTest.php | 1 + .../OnboardingVerificationAssistTest.php | 4 +- .../Onboarding/OnboardingVerificationTest.php | 9 +- .../QueuedExecutionContractMatrixTest.php | 4 +- .../Feature/PolicySyncServiceReportTest.php | 2 +- .../tests/Feature/PolicySyncServiceTest.php | 2 +- .../DisabledActionsTooltipTest.php | 2 +- .../MvpProviderScopeTest.php | 3 + .../ProviderConnectionCutoverSpec081Test.php | 6 +- .../ProviderConnectionEnableDisableTest.php | 91 +++- .../ProviderConnectionHealthCheckJobTest.php | 44 +- ...rConnectionHealthCheckStartSurfaceTest.php | 6 +- ...rConnectionMigrationClassificationTest.php | 4 +- ...viderConnectionTruthCleanupSpec179Test.php | 68 ++- ...nectionViewsDbOnlyRenderingSpec081Test.php | 9 +- ...ProviderGatewayRuntimeSmokeSpec081Test.php | 2 +- ...derOperationBlockedGuidanceSpec081Test.php | 2 +- .../ProviderOperationConcurrencyTest.php | 6 +- .../RequiredFiltersTest.php | 6 +- .../TenantFilterOverrideTest.php | 11 +- .../MicrosoftGraphOptionsResolverTest.php | 3 +- ...ditProviderConnectionUiEnforcementTest.php | 4 +- .../OnboardingWizardUiEnforcementTest.php | 4 +- .../System/Spec114/DirectoryTenantsTest.php | 112 ++++- .../TenantProviderConnectionsCtaTest.php | 37 ++ .../ProviderExecutionReauthorizationTest.php | 6 +- .../VerificationAuthorizationTest.php | 2 +- .../VerificationStartAfterCompletionTest.php | 2 +- .../VerificationStartDedupeTest.php | 4 +- apps/platform/tests/Pest.php | 8 +- .../tests/Unit/Badges/BadgeCatalogTest.php | 9 + .../Badges/ProviderConnectionBadgesTest.php | 44 +- .../tests/Unit/PolicySnapshotServiceTest.php | 2 +- .../ProviderConnectionBadgeMappingTest.php | 19 +- .../ProviderConnectionClassifierTest.php | 3 +- .../ProviderOperationStartGateTest.php | 6 +- .../tests/Unit/RbacOnboardingServiceTest.php | 2 +- .../tests/Unit/ScopeTagResolverTest.php | 8 +- docs/product/principles.md | 7 +- docs/product/roadmap.md | 8 +- docs/product/spec-candidates.md | 38 +- .../checklists/requirements.md | 36 ++ ...ider-connection-state-cleanup.openapi.yaml | 431 ++++++++++++++++++ .../data-model.md | 272 +++++++++++ .../plan.md | 308 +++++++++++++ .../quickstart.md | 183 ++++++++ .../research.md | 74 +++ .../spec.md | 248 ++++++++++ .../tasks.md | 207 +++++++++ 105 files changed, 2666 insertions(+), 750 deletions(-) delete mode 100644 apps/platform/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php delete mode 100644 apps/platform/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php create mode 100644 apps/platform/database/migrations/2026_04_09_000001_add_is_enabled_to_provider_connections.php create mode 100644 apps/platform/database/migrations/2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php create mode 100644 apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php create mode 100644 specs/188-provider-connection-state-cleanup/checklists/requirements.md create mode 100644 specs/188-provider-connection-state-cleanup/contracts/provider-connection-state-cleanup.openapi.yaml create mode 100644 specs/188-provider-connection-state-cleanup/data-model.md create mode 100644 specs/188-provider-connection-state-cleanup/plan.md create mode 100644 specs/188-provider-connection-state-cleanup/quickstart.md create mode 100644 specs/188-provider-connection-state-cleanup/research.md create mode 100644 specs/188-provider-connection-state-cleanup/spec.md create mode 100644 specs/188-provider-connection-state-cleanup/tasks.md diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index d0d2c77a..9ec7dd74 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -161,6 +161,8 @@ ## Active Technologies - PostgreSQL with existing tenant-owned `tenants`, `backup_sets`, `backup_items`, `restore_runs`, `policies`, and membership records; no schema change planned (186-tenant-registry-recovery-triage) - PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages (187-portfolio-triage-arrival-context) - PostgreSQL unchanged; no new tables, caches, or durable workflow artifacts (187-portfolio-triage-arrival-context) +- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries (188-provider-connection-state-cleanup) +- PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes (188-provider-connection-state-cleanup) - PHP 8.4.15 (feat/005-bulk-operations) @@ -195,8 +197,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 188-provider-connection-state-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries - 187-portfolio-triage-arrival-context: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `TenantResource`, `TenantDashboard`, `CanonicalAdminTenantFilterState`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, and continuity-aware backup or restore list pages - 186-tenant-registry-recovery-triage: Added PHP 8.4, Laravel 12, Blade, Filament v5, Livewire v4 + Filament v5 resources and table filters, Livewire v4 `ListRecords`, Pest v4, Laravel Sail, existing `TenantResource`, `ListTenants`, `WorkspaceOverviewBuilder`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, `RecoveryReadiness`, and shared badge infrastructure -- 185-workspace-recovery-posture-visibility: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, existing `WorkspaceOverviewBuilder`, `WorkspaceSummaryStats`, `WorkspaceNeedsAttention`, `TenantBackupHealthResolver`, `TenantBackupHealthAssessment`, `RestoreSafetyResolver`, tenant dashboard widgets, `WorkspaceCapabilityResolver`, `CapabilityResolver`, and the current workspace overview Blade surfaces diff --git a/apps/platform/app/Console/Commands/ClassifyProviderConnections.php b/apps/platform/app/Console/Commands/ClassifyProviderConnections.php index 78a117d2..bf52155e 100644 --- a/apps/platform/app/Console/Commands/ClassifyProviderConnections.php +++ b/apps/platform/app/Console/Commands/ClassifyProviderConnections.php @@ -10,7 +10,6 @@ use App\Services\Intune\AuditLogger; use App\Services\Providers\ProviderConnectionClassificationResult; use App\Services\Providers\ProviderConnectionClassifier; -use App\Services\Providers\ProviderConnectionStateProjector; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderCredentialKind; use App\Support\Providers\ProviderCredentialSource; @@ -29,10 +28,8 @@ class ClassifyProviderConnections extends Command protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.'; - public function handle( - ProviderConnectionClassifier $classifier, - ProviderConnectionStateProjector $stateProjector, - ): int { + public function handle(ProviderConnectionClassifier $classifier): int + { $query = $this->query(); $write = (bool) $this->option('write'); $chunkSize = max(1, (int) $this->option('chunk')); @@ -62,7 +59,6 @@ public function handle( ->orderBy('id') ->chunkById($chunkSize, function ($connections) use ( $classifier, - $stateProjector, $write, $tenantCounts, &$startedTenants, @@ -101,7 +97,7 @@ public function handle( $startedTenants[$tenantKey] = true; } - $connection = $this->applyClassification($connection, $result, $stateProjector); + $connection = $this->applyClassification($connection, $result); $this->auditApplied($tenant, $connection, $result); $appliedCount++; } @@ -146,11 +142,10 @@ private function query(): Builder private function applyClassification( ProviderConnection $connection, ProviderConnectionClassificationResult $result, - ProviderConnectionStateProjector $stateProjector, ): ProviderConnection { - DB::transaction(function () use ($connection, $result, $stateProjector): void { + DB::transaction(function () use ($connection, $result): void { $connection->forceFill( - $connection->classificationProjection($result, $stateProjector) + $connection->classificationProjection($result) )->save(); $credential = $connection->credential; diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index 317dcfad..9cc1790b 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -30,7 +30,6 @@ use App\Services\Onboarding\OnboardingLifecycleService; use App\Services\OperationRunService; use App\Services\Providers\ProviderConnectionMutationService; -use App\Services\Providers\ProviderConnectionStateProjector; use App\Services\Providers\ProviderOperationRegistry; use App\Services\Providers\ProviderOperationStartGate; use App\Services\Tenants\TenantOperabilityService; @@ -2535,12 +2534,6 @@ public function createProviderConnection(array $data): void /** @var ProviderConnection $connection */ $connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection { - $projectedState = app(ProviderConnectionStateProjector::class)->project( - connectionType: ProviderConnectionType::Platform, - consentStatus: ProviderConsentStatus::Required, - verificationStatus: ProviderVerificationStatus::Unknown, - ); - $connection = ProviderConnection::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('provider', 'microsoft') @@ -2554,15 +2547,14 @@ public function createProviderConnection(array $data): void 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => $displayName, + 'is_enabled' => true, 'connection_type' => ProviderConnectionType::Platform->value, - 'status' => $projectedState['status'], 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, 'consent_last_checked_at' => null, 'consent_error_code' => null, 'consent_error_message' => null, 'verification_status' => ProviderVerificationStatus::Unknown->value, - 'health_status' => $projectedState['health_status'], 'migration_review_required' => false, 'migration_reviewed_at' => null, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index bfd0eafc..56a7d090 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -472,6 +472,11 @@ private static function consentStatusLabelFromState(mixed $state): string return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label; } + private static function lifecycleLabelFromState(mixed $state): string + { + return BadgeRenderer::spec(BadgeDomain::BooleanEnabled, $state)->label; + } + private static function verificationStatusLabelFromState(mixed $state): string { return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; @@ -512,6 +517,9 @@ public static function form(Schema $schema): Schema ->columnSpanFull(), Section::make('Current state') ->schema([ + Placeholder::make('is_enabled_display') + ->label('Lifecycle') + ->content(fn (?ProviderConnection $record): string => static::lifecycleLabelFromState($record?->is_enabled)), Placeholder::make('consent_status_display') ->label('Consent') ->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)), @@ -526,12 +534,6 @@ public static function form(Schema $schema): Schema ->columnSpanFull(), Section::make('Diagnostics') ->schema([ - Placeholder::make('status_display') - ->label('Legacy status') - ->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionStatus, $record?->status)->label), - Placeholder::make('health_status_display') - ->label('Legacy health') - ->content(fn (?ProviderConnection $record): string => BadgeRenderer::spec(BadgeDomain::ProviderConnectionHealth, $record?->health_status)->label), Placeholder::make('migration_review_status_display') ->label('Migration review') ->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record)) @@ -578,6 +580,13 @@ public static function infolist(Schema $schema): Schema ->columns(2), Section::make('Current state') ->schema([ + Infolists\Components\TextEntry::make('is_enabled') + ->label('Lifecycle') + ->badge() + ->formatStateUsing(fn ($state): string => static::lifecycleLabelFromState($state)) + ->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled)) + ->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)), Infolists\Components\TextEntry::make('consent_status') ->label('Consent') ->badge() @@ -599,20 +608,6 @@ public static function infolist(Schema $schema): Schema ->columns(2), Section::make('Diagnostics') ->schema([ - Infolists\Components\TextEntry::make('status') - ->label('Legacy status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) - ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)), - Infolists\Components\TextEntry::make('health_status') - ->label('Legacy health') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) - ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) - ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)), Infolists\Components\TextEntry::make('migration_review_required') ->label('Migration review') ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) @@ -684,6 +679,13 @@ public static function table(Table $table): Table ? 'Dedicated' : 'Platform') ->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'), + Tables\Columns\TextColumn::make('is_enabled') + ->label('Lifecycle') + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanEnabled)) + ->color(BadgeRenderer::color(BadgeDomain::BooleanEnabled)) + ->icon(BadgeRenderer::icon(BadgeDomain::BooleanEnabled)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanEnabled)), Tables\Columns\TextColumn::make('consent_status') ->label('Consent') ->badge() @@ -698,22 +700,6 @@ public static function table(Table $table): Table ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)), - Tables\Columns\TextColumn::make('status') - ->label('Legacy status') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionStatus)) - ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionStatus)) - ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionStatus)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionStatus)) - ->toggleable(isToggledHiddenByDefault: true), - Tables\Columns\TextColumn::make('health_status') - ->label('Legacy health') - ->badge() - ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderConnectionHealth)) - ->color(BadgeRenderer::color(BadgeDomain::ProviderConnectionHealth)) - ->icon(BadgeRenderer::icon(BadgeDomain::ProviderConnectionHealth)) - ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderConnectionHealth)) - ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('migration_review_required') ->label('Migration review') ->badge() @@ -796,12 +782,10 @@ public static function table(Table $table): Table return $query->where('provider_connections.verification_status', $value); }), - SelectFilter::make('status') - ->label('Diagnostic status') + SelectFilter::make('is_enabled') + ->label('Lifecycle') ->options([ - 'connected' => 'Connected', - 'needs_consent' => 'Needs consent', - 'error' => 'Error', + 'enabled' => 'Enabled', 'disabled' => 'Disabled', ]) ->query(function (Builder $query, array $data): Builder { @@ -811,24 +795,7 @@ public static function table(Table $table): Table return $query; } - return $query->where('provider_connections.status', $value); - }), - SelectFilter::make('health_status') - ->label('Diagnostic health') - ->options([ - 'ok' => 'OK', - 'degraded' => 'Degraded', - 'down' => 'Down', - 'unknown' => 'Unknown', - ]) - ->query(function (Builder $query, array $data): Builder { - $value = $data['value'] ?? null; - - if (! is_string($value) || $value === '') { - return $query; - } - - return $query->where('provider_connections.health_status', $value); + return $query->where('provider_connections.is_enabled', $value === 'enabled'); }), Filter::make('default_only') ->label('Default only') @@ -847,7 +814,7 @@ public static function table(Table $table): Table ->label('Check connection') ->icon('heroicon-o-check-badge') ->color('success') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled) ->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void { $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); @@ -946,7 +913,7 @@ public static function table(Table $table): Table ->label('Inventory sync') ->icon('heroicon-o-arrow-path') ->color('info') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); @@ -1043,7 +1010,7 @@ public static function table(Table $table): Table ->label('Compliance snapshot') ->icon('heroicon-o-shield-check') ->color('info') - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void { $tenant = static::resolveTenantForRecord($record); $user = auth()->user(); @@ -1141,7 +1108,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-star') ->color('primary') ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled' && ! $record->is_default) + ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled && ! $record->is_default) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = static::resolveTenantForRecord($record); @@ -1383,7 +1350,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-play') ->color('success') ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = static::resolveTenantForRecord($record); @@ -1392,15 +1359,14 @@ public static function table(Table $table): Table } $hadCredentials = $record->credential()->exists(); - $previousStatus = (string) $record->status; - - $status = $hadCredentials ? 'connected' : 'error'; + $previousLifecycle = (bool) $record->is_enabled; + $verificationStatus = $hadCredentials ? ProviderVerificationStatus::Unknown : ProviderVerificationStatus::Blocked; $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; $record->update([ - 'status' => $status, - 'health_status' => 'unknown', + 'is_enabled' => true, + 'verification_status' => $verificationStatus->value, 'last_health_check_at' => null, 'last_error_reason_code' => $errorReasonCode, 'last_error_message' => $errorMessage, @@ -1418,8 +1384,9 @@ public static function table(Table $table): Table 'metadata' => [ 'provider' => $record->provider, 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - 'to_status' => $status, + 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', + 'to_lifecycle' => 'enabled', + 'verification_status' => $verificationStatus->value, 'credentials_present' => $hadCredentials, ], ], @@ -1457,7 +1424,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-archive-box-x-mark') ->color('danger') ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = static::resolveTenantForRecord($record); @@ -1465,10 +1432,10 @@ public static function table(Table $table): Table return; } - $previousStatus = (string) $record->status; + $previousLifecycle = (bool) $record->is_enabled; $record->update([ - 'status' => 'disabled', + 'is_enabled' => false, ]); $user = auth()->user(); @@ -1483,7 +1450,8 @@ public static function table(Table $table): Table 'metadata' => [ 'provider' => $record->provider, 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, + 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', + 'to_lifecycle' => 'disabled', ], ], actorId: $actorId, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php index a47da8bf..f9b3c94c 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/CreateProviderConnection.php @@ -6,7 +6,6 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Intune\AuditLogger; -use App\Services\Providers\ProviderConnectionStateProjector; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; @@ -30,27 +29,20 @@ protected function mutateFormDataBeforeCreate(array $data): array $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); - $projectedState = app(ProviderConnectionStateProjector::class)->project( - connectionType: ProviderConnectionType::Platform, - consentStatus: ProviderConsentStatus::Required, - verificationStatus: ProviderVerificationStatus::Unknown, - ); - return [ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => $data['entra_tenant_id'], 'display_name' => $data['display_name'], + 'is_enabled' => true, 'connection_type' => ProviderConnectionType::Platform->value, - 'status' => $projectedState['status'], 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, 'consent_last_checked_at' => null, 'consent_error_code' => null, 'consent_error_message' => null, 'verification_status' => ProviderVerificationStatus::Unknown->value, - 'health_status' => $projectedState['health_status'], 'migration_review_required' => false, 'migration_reviewed_at' => null, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 4ed02f46..36760d3a 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -212,7 +212,7 @@ protected function getHeaderActions(): array return $tenant instanceof Tenant && $user instanceof User && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; + && (bool) $record->is_enabled; }) ->action(function (ProviderConnection $record, StartVerification $verification): void { $tenant = $this->currentTenant(); @@ -521,7 +521,7 @@ protected function getHeaderActions(): array ->color('primary') ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && $record->status !== 'disabled' + && (bool) $record->is_enabled && ! $record->is_default && ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) @@ -581,7 +581,7 @@ protected function getHeaderActions(): array return $tenant instanceof Tenant && $user instanceof User && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; + && (bool) $record->is_enabled; }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { $tenant = $this->currentTenant(); @@ -695,7 +695,7 @@ protected function getHeaderActions(): array return $tenant instanceof Tenant && $user instanceof User && $user->canAccessTenant($tenant) - && $record->status !== 'disabled'; + && (bool) $record->is_enabled; }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { $tenant = $this->currentTenant(); @@ -803,7 +803,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-play') ->color('success') ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $record->status === 'disabled') + ->visible(fn (ProviderConnection $record): bool => ! (bool) $record->is_enabled) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = $this->currentTenant(); @@ -812,15 +812,19 @@ protected function getHeaderActions(): array } $hadCredentials = $record->credential()->exists(); - $previousStatus = (string) $record->status; - - $status = $hadCredentials ? 'connected' : 'needs_consent'; + $previousLifecycle = (bool) $record->is_enabled; + $verificationStatus = $hadCredentials ? \App\Support\Providers\ProviderVerificationStatus::Unknown : \App\Support\Providers\ProviderVerificationStatus::Blocked; $errorReasonCode = null; $errorMessage = null; + if (! $hadCredentials) { + $errorReasonCode = \App\Support\Providers\ProviderReasonCodes::ProviderCredentialMissing; + $errorMessage = 'Provider connection credentials are missing.'; + } + $record->update([ - 'status' => $status, - 'health_status' => 'unknown', + 'is_enabled' => true, + 'verification_status' => $verificationStatus->value, 'last_health_check_at' => null, 'last_error_reason_code' => $errorReasonCode, 'last_error_message' => $errorMessage, @@ -838,8 +842,9 @@ protected function getHeaderActions(): array 'metadata' => [ 'provider' => $record->provider, 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, - 'to_status' => $status, + 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', + 'to_lifecycle' => 'enabled', + 'verification_status' => $verificationStatus->value, 'credentials_present' => $hadCredentials, ], ], @@ -853,8 +858,8 @@ protected function getHeaderActions(): array if (! $hadCredentials) { Notification::make() - ->title('Connection enabled (needs consent)') - ->body('Grant admin consent before running checks or operations.') + ->title('Connection enabled (credentials missing)') + ->body('Add credentials before running checks or operations.') ->warning() ->send(); @@ -878,7 +883,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-archive-box-x-mark') ->color('danger') ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => $record->status !== 'disabled') + ->visible(fn (ProviderConnection $record): bool => (bool) $record->is_enabled) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = $this->currentTenant(); @@ -886,10 +891,10 @@ protected function getHeaderActions(): array return; } - $previousStatus = (string) $record->status; + $previousLifecycle = (bool) $record->is_enabled; $record->update([ - 'status' => 'disabled', + 'is_enabled' => false, ]); $user = auth()->user(); @@ -904,7 +909,8 @@ protected function getHeaderActions(): array 'metadata' => [ 'provider' => $record->provider, 'entra_tenant_id' => $record->entra_tenant_id, - 'from_status' => $previousStatus, + 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled', + 'to_lifecycle' => 'disabled', ], ], actorId: $actorId, diff --git a/apps/platform/app/Filament/Resources/TenantResource.php b/apps/platform/app/Filament/Resources/TenantResource.php index cc8e2290..1227c95b 100644 --- a/apps/platform/app/Filament/Resources/TenantResource.php +++ b/apps/platform/app/Filament/Resources/TenantResource.php @@ -2084,10 +2084,12 @@ public static function adminConsentUrl(Tenant $tenant): ?string * @return array{ * state:string, * cta_url:string, + * lifecycle:?string, + * is_enabled:?bool, * display_name:?string, * provider:?string, - * status:?string, - * health_status:?string, + * consent_status:?string, + * verification_status:?string, * last_health_check_at:?string, * last_error_reason_code:?string * } @@ -2118,10 +2120,10 @@ private static function providerConnectionState(Tenant $tenant): array 'needs_default_connection' => false, 'display_name' => null, 'provider' => null, + 'lifecycle' => null, + 'is_enabled' => null, 'consent_status' => null, 'verification_status' => null, - 'status' => null, - 'health_status' => null, 'last_health_check_at' => null, 'last_error_reason_code' => null, ]; @@ -2133,14 +2135,14 @@ private static function providerConnectionState(Tenant $tenant): array 'needs_default_connection' => ! $connection->is_default, 'display_name' => (string) $connection->display_name, 'provider' => (string) $connection->provider, + 'lifecycle' => (bool) $connection->is_enabled ? 'enabled' : 'disabled', + 'is_enabled' => (bool) $connection->is_enabled, 'consent_status' => $connection->consent_status instanceof BackedEnum ? (string) $connection->consent_status->value : (is_string($connection->consent_status) ? $connection->consent_status : null), 'verification_status' => $connection->verification_status instanceof BackedEnum ? (string) $connection->verification_status->value : (is_string($connection->verification_status) ? $connection->verification_status : null), - 'status' => is_string($connection->status) ? $connection->status : null, - 'health_status' => is_string($connection->health_status) ? $connection->health_status : null, 'last_health_check_at' => optional($connection->last_health_check_at)->toDateTimeString(), 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null, ]; diff --git a/apps/platform/app/Filament/System/Pages/Directory/Tenants.php b/apps/platform/app/Filament/System/Pages/Directory/Tenants.php index b00fd5e8..bcf17044 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/Tenants.php +++ b/apps/platform/app/Filament/System/Pages/Directory/Tenants.php @@ -68,8 +68,14 @@ public function table(Table $table): Table return Tenant::query() ->with('workspace') ->withCount([ - 'providerConnections', - 'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'), + 'providerConnections as critical_provider_connections_count' => fn (Builder $query): Builder => $query + ->where('provider', 'microsoft') + ->where('is_default', true) + ->whereIn('verification_status', ['blocked', 'error']), + 'providerConnections as warning_provider_connections_count' => fn (Builder $query): Builder => $query + ->where('provider', 'microsoft') + ->where('is_default', true) + ->where('verification_status', 'degraded'), 'permissions as missing_permissions_count' => fn (Builder $query): Builder => $query->where('status', '!=', 'granted'), ]); }) @@ -108,11 +114,14 @@ private function healthForTenant(Tenant $tenant): string return 'unknown'; } - if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) { + if ((int) ($tenant->getAttribute('critical_provider_connections_count') ?? 0) > 0) { return 'critical'; } - if ((int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0) { + if ( + (int) ($tenant->getAttribute('warning_provider_connections_count') ?? 0) > 0 + || (int) ($tenant->getAttribute('missing_permissions_count') ?? 0) > 0 + ) { return 'warn'; } diff --git a/apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php b/apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php index da2dc139..37394774 100644 --- a/apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php +++ b/apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php @@ -50,7 +50,17 @@ public function providerConnections(): Collection ->where('tenant_id', (int) $this->tenant->getKey()) ->orderByDesc('is_default') ->orderBy('provider') - ->get(['id', 'provider', 'status', 'health_status', 'is_default', 'last_health_check_at']); + ->get([ + 'id', + 'display_name', + 'provider', + 'is_default', + 'is_enabled', + 'consent_status', + 'verification_status', + 'last_health_check_at', + 'last_error_reason_code', + ]); } /** diff --git a/apps/platform/app/Http/Controllers/AdminConsentCallbackController.php b/apps/platform/app/Http/Controllers/AdminConsentCallbackController.php index 71fbb342..2fbced50 100644 --- a/apps/platform/app/Http/Controllers/AdminConsentCallbackController.php +++ b/apps/platform/app/Http/Controllers/AdminConsentCallbackController.php @@ -5,7 +5,6 @@ use App\Models\ProviderConnection; use App\Models\Tenant; use App\Services\Intune\AuditLogger; -use App\Services\Providers\ProviderConnectionStateProjector; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; @@ -144,11 +143,6 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat default => ProviderConsentStatus::Required, }; $verificationStatus = ProviderVerificationStatus::Unknown; - $projectedState = app(ProviderConnectionStateProjector::class)->project( - connectionType: ProviderConnectionType::Platform, - consentStatus: $consentStatus, - verificationStatus: $verificationStatus, - ); $reasonCode = match ($status) { 'ok' => null, 'error' => ProviderReasonCodes::ProviderAuthFailed, @@ -164,19 +158,18 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat [ 'workspace_id' => (int) $tenant->workspace_id, 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), + 'is_enabled' => true, 'connection_type' => ProviderConnectionType::Platform->value, - 'status' => $projectedState['status'], 'consent_status' => $consentStatus->value, 'consent_granted_at' => $status === 'ok' ? now() : null, 'consent_last_checked_at' => now(), - 'consent_error_code' => $reasonCode, - 'consent_error_message' => $error, + 'consent_error_code' => $status === 'error' ? $reasonCode : null, + 'consent_error_message' => $status === 'error' ? $error : null, 'verification_status' => $verificationStatus->value, - 'health_status' => $projectedState['health_status'], 'migration_review_required' => false, 'migration_reviewed_at' => null, 'last_error_reason_code' => $reasonCode, - 'last_error_message' => $error, + 'last_error_message' => $status === 'ok' ? null : $error, 'is_default' => $hasDefault ? false : true, ], ); diff --git a/apps/platform/app/Http/Controllers/TenantOnboardingController.php b/apps/platform/app/Http/Controllers/TenantOnboardingController.php index ed48eaf7..641d484c 100644 --- a/apps/platform/app/Http/Controllers/TenantOnboardingController.php +++ b/apps/platform/app/Http/Controllers/TenantOnboardingController.php @@ -6,7 +6,6 @@ use App\Models\Tenant; use App\Services\Intune\AuditLogger; use App\Services\Providers\AdminConsentUrlFactory; -use App\Services\Providers\ProviderConnectionStateProjector; use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; @@ -100,12 +99,6 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection ->where('is_default', true) ->exists(); - $projectedState = app(ProviderConnectionStateProjector::class)->project( - connectionType: ProviderConnectionType::Platform, - consentStatus: ProviderConsentStatus::Required, - verificationStatus: ProviderVerificationStatus::Unknown, - ); - $connection = ProviderConnection::query()->updateOrCreate( [ 'tenant_id' => (int) $tenant->getKey(), @@ -115,15 +108,14 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection [ 'workspace_id' => (int) $tenant->workspace_id, 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), + 'is_enabled' => true, 'connection_type' => ProviderConnectionType::Platform->value, - 'status' => $projectedState['status'], 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, 'consent_last_checked_at' => null, 'consent_error_code' => null, 'consent_error_message' => null, 'verification_status' => ProviderVerificationStatus::Unknown->value, - 'health_status' => $projectedState['health_status'], 'migration_review_required' => false, 'migration_reviewed_at' => null, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, diff --git a/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php b/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php index e33cc827..a6fc6ab6 100644 --- a/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php +++ b/apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php @@ -385,8 +385,6 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult $connection->update([ 'consent_status' => $projected['consent_status'], 'verification_status' => $projected['verification_status'], - 'status' => $projected['status'], - 'health_status' => $projected['health_status'], 'last_health_check_at' => now(), 'last_error_reason_code' => $projected['last_error_reason_code'], 'last_error_message' => $projected['last_error_message'], @@ -449,12 +447,11 @@ private function logVerificationResult( 'metadata' => [ 'provider_connection_id' => (int) $connection->getKey(), 'connection_type' => $connection->connection_type?->value ?? $connection->connection_type, + 'is_enabled' => (bool) $connection->is_enabled, 'consent_status' => $connection->consent_status?->value ?? $connection->consent_status, 'verification_status' => $connection->verification_status?->value ?? $connection->verification_status, 'credential_source' => $identity->credentialSource, 'effective_client_id' => $identity->effectiveClientId, - 'status' => $connection->status, - 'health_status' => $connection->health_status, 'reason_code' => $reasonCode, 'operation_run_id' => (int) $run->getKey(), 'previous_consent_status' => $previousConsentStatus, diff --git a/apps/platform/app/Jobs/ScanEntraAdminRolesJob.php b/apps/platform/app/Jobs/ScanEntraAdminRolesJob.php index b98493ed..6305db0e 100644 --- a/apps/platform/app/Jobs/ScanEntraAdminRolesJob.php +++ b/apps/platform/app/Jobs/ScanEntraAdminRolesJob.php @@ -13,6 +13,7 @@ use App\Support\OperationRunOutcome; use App\Support\OperationRunStatus; use App\Support\OpsUx\RunFailureSanitizer; +use App\Support\Providers\ProviderConsentStatus; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -44,7 +45,10 @@ public function handle( // FR-018: Skip tenants without active provider connection $hasConnection = ProviderConnection::query() ->where('tenant_id', $tenant->getKey()) - ->where('status', 'connected') + ->where('provider', 'microsoft') + ->where('is_default', true) + ->where('is_enabled', true) + ->where('consent_status', ProviderConsentStatus::Granted->value) ->exists(); if (! $hasConnection) { diff --git a/apps/platform/app/Models/ProviderConnection.php b/apps/platform/app/Models/ProviderConnection.php index 1ffc1eff..78f646c3 100644 --- a/apps/platform/app/Models/ProviderConnection.php +++ b/apps/platform/app/Models/ProviderConnection.php @@ -21,6 +21,7 @@ class ProviderConnection extends Model protected $casts = [ 'is_default' => 'boolean', + 'is_enabled' => 'boolean', 'connection_type' => ProviderConnectionType::class, 'consent_status' => ProviderConsentStatus::class, 'consent_granted_at' => 'datetime', @@ -151,7 +152,6 @@ public function requiresMigrationReview(): bool */ public function classificationProjection( \App\Services\Providers\ProviderConnectionClassificationResult $result, - \App\Services\Providers\ProviderConnectionStateProjector $stateProjector, ): array { $metadata = array_merge( is_array($this->metadata) ? $this->metadata : [], @@ -166,17 +166,8 @@ public function classificationProjection( ]; if ($result->reviewRequired) { - $statusProjection = $stateProjector->project( - connectionType: $result->suggestedConnectionType, - consentStatus: $this->consent_status, - verificationStatus: ProviderVerificationStatus::Blocked, - currentStatus: is_string($this->status) ? $this->status : null, - ); - return $projection + [ 'verification_status' => ProviderVerificationStatus::Blocked->value, - 'status' => $statusProjection['status'], - 'health_status' => $statusProjection['health_status'], 'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired, 'last_error_message' => 'Legacy provider connection requires explicit migration review.', 'migration_reviewed_at' => null, @@ -197,19 +188,10 @@ public function classificationProjection( $this->migration_review_required || $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired ) { - $statusProjection = $stateProjector->project( - connectionType: $result->suggestedConnectionType, - consentStatus: $this->consent_status, - verificationStatus: $currentVerificationStatus, - currentStatus: is_string($this->status) ? $this->status : null, - ); - return $projection + [ 'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus ? $currentVerificationStatus->value : $currentVerificationStatus, - 'status' => $statusProjection['status'], - 'health_status' => $statusProjection['health_status'], 'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired ? null : $currentReasonCode, diff --git a/apps/platform/app/Services/Providers/Contracts/HealthResult.php b/apps/platform/app/Services/Providers/Contracts/HealthResult.php index af500704..a556f840 100644 --- a/apps/platform/app/Services/Providers/Contracts/HealthResult.php +++ b/apps/platform/app/Services/Providers/Contracts/HealthResult.php @@ -2,6 +2,8 @@ namespace App\Services\Providers\Contracts; +use App\Support\Providers\ProviderVerificationStatus; + final class HealthResult { /** @@ -9,8 +11,7 @@ final class HealthResult */ public function __construct( public readonly bool $healthy, - public readonly string $status, - public readonly string $healthStatus, + public readonly string $verificationStatus, public readonly ?string $reasonCode = null, public readonly ?string $message = null, public readonly array $meta = [], @@ -19,9 +20,9 @@ public function __construct( /** * @param array $meta */ - public static function ok(string $status = 'connected', string $healthStatus = 'ok', array $meta = []): self + public static function ok(array $meta = []): self { - return new self(true, $status, $healthStatus, null, null, $meta); + return new self(true, ProviderVerificationStatus::Healthy->value, null, null, $meta); } /** @@ -30,10 +31,9 @@ public static function ok(string $status = 'connected', string $healthStatus = ' public static function failed( string $reasonCode, string $message, - string $status = 'error', - string $healthStatus = 'down', + string $verificationStatus = 'error', array $meta = [], ): self { - return new self(false, $status, $healthStatus, $reasonCode, $message, $meta); + return new self(false, $verificationStatus, $reasonCode, $message, $meta); } } diff --git a/apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php b/apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php index 012bbc1b..03fcf515 100644 --- a/apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php +++ b/apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php @@ -8,6 +8,7 @@ use App\Services\Providers\Contracts\ProviderHealthCheck; use App\Support\OpsUx\RunFailureSanitizer; use App\Support\Providers\ProviderReasonCodes; +use App\Support\Providers\ProviderVerificationStatus; use Throwable; final class MicrosoftProviderHealthCheck implements ProviderHealthCheck @@ -25,15 +26,12 @@ public function check(ProviderConnection $connection): HealthResult return HealthResult::failed( reasonCode: $reasonCode, message: $message !== '' ? $message : 'Health check failed.', - status: $this->statusForReason($reasonCode), - healthStatus: $this->healthForReason($reasonCode), + verificationStatus: $this->verificationStatusForReason($reasonCode), ); } if ($response->successful()) { return HealthResult::ok( - status: 'connected', - healthStatus: 'ok', meta: [ 'organization_id' => $response->data['id'] ?? null, 'organization_display_name' => $response->data['displayName'] ?? null, @@ -47,8 +45,7 @@ public function check(ProviderConnection $connection): HealthResult return HealthResult::failed( reasonCode: $reasonCode, message: $message !== '' ? $message : 'Health check failed.', - status: $this->statusForReason($reasonCode), - healthStatus: $this->healthForReason($reasonCode), + verificationStatus: $this->verificationStatusForReason($reasonCode), meta: [ 'http_status' => $response->status, ], @@ -89,24 +86,14 @@ private function messageForResponse(GraphResponse $response): string return 'Health check failed.'; } - private function statusForReason(string $reasonCode): string + private function verificationStatusForReason(string $reasonCode): string { return match ($reasonCode) { - ProviderReasonCodes::ProviderAuthFailed, - ProviderReasonCodes::ProviderPermissionDenied, - ProviderReasonCodes::ProviderConsentMissing => 'needs_consent', - default => 'error', - }; - } - - private function healthForReason(string $reasonCode): string - { - return match ($reasonCode) { - ProviderReasonCodes::RateLimited => 'degraded', - ProviderReasonCodes::NetworkUnreachable, - ProviderReasonCodes::ProviderAuthFailed, - ProviderReasonCodes::ProviderPermissionDenied => 'down', - default => 'down', + ProviderReasonCodes::RateLimited => ProviderVerificationStatus::Degraded->value, + ProviderReasonCodes::ProviderConsentMissing, + ProviderReasonCodes::ProviderConsentFailed, + ProviderReasonCodes::ProviderConsentRevoked => ProviderVerificationStatus::Blocked->value, + default => ProviderVerificationStatus::Error->value, }; } } diff --git a/apps/platform/app/Services/Providers/ProviderConnectionClassifier.php b/apps/platform/app/Services/Providers/ProviderConnectionClassifier.php index f2614182..3d26b9d6 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionClassifier.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionClassifier.php @@ -50,9 +50,9 @@ public function classify( 'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null, 'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false), 'current_connection_type' => $currentConnectionType, + 'is_enabled' => (bool) $connection->is_enabled, 'consent_status' => $this->enumValue($connection->consent_status), 'verification_status' => $this->enumValue($connection->verification_status), - 'status' => is_string($connection->status) ? $connection->status : null, 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null, ], effectiveApp: $this->effectiveAppMetadata( diff --git a/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php b/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php index ee4b42b1..8b4b6f10 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php @@ -15,10 +15,7 @@ final class ProviderConnectionMutationService { - public function __construct( - private readonly CredentialManager $credentials, - private readonly ProviderConnectionStateProjector $stateProjector, - ) {} + public function __construct(private readonly CredentialManager $credentials) {} public function enableDedicatedOverride( ProviderConnection $connection, @@ -50,15 +47,10 @@ public function enableDedicatedOverride( : $this->normalizeConsentStatus($connection->consent_status); $verificationStatus = ProviderVerificationStatus::Unknown; - $updates = $this->projectConnectionState( - connection: $connection, - connectionType: ProviderConnectionType::Dedicated, - consentStatus: $consentStatus, - verificationStatus: $verificationStatus, - ); - - $connection->forceFill(array_merge($updates, [ + $connection->forceFill([ 'connection_type' => ProviderConnectionType::Dedicated->value, + 'consent_status' => $consentStatus->value, + 'verification_status' => $verificationStatus->value, 'last_health_check_at' => null, 'last_error_reason_code' => $needsConsentReset ? ProviderReasonCodes::ProviderConsentMissing @@ -67,9 +59,9 @@ public function enableDedicatedOverride( 'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []), 'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at, 'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at, - 'consent_error_code' => $needsConsentReset ? null : $connection->consent_error_code, - 'consent_error_message' => $needsConsentReset ? null : $connection->consent_error_message, - ]))->save(); + 'consent_error_code' => null, + 'consent_error_message' => null, + ])->save(); $this->credentials->upsertClientSecretCredential( connection: $connection->fresh(), @@ -90,15 +82,9 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec $connection->credential->delete(); } - $updates = $this->projectConnectionState( - connection: $connection, - connectionType: ProviderConnectionType::Platform, - consentStatus: ProviderConsentStatus::Required, - verificationStatus: ProviderVerificationStatus::Unknown, - ); - - $connection->forceFill(array_merge($updates, [ + $connection->forceFill([ 'connection_type' => ProviderConnectionType::Platform->value, + 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, 'consent_last_checked_at' => null, 'consent_error_code' => null, @@ -108,7 +94,7 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_message' => null, 'scopes_granted' => [], - ]))->save(); + ])->save(); return $connection->fresh(['credential']); }); @@ -126,47 +112,19 @@ public function deleteDedicatedCredential(ProviderConnection $connection): Provi $consentStatus = $this->normalizeConsentStatus($connection->consent_status); $verificationStatus = ProviderVerificationStatus::Blocked; - $updates = $this->projectConnectionState( - connection: $connection, - connectionType: ProviderConnectionType::Dedicated, - consentStatus: $consentStatus, - verificationStatus: $verificationStatus, - ); - - $connection->forceFill(array_merge($updates, [ + $connection->forceFill([ 'connection_type' => ProviderConnectionType::Dedicated->value, 'consent_status' => $consentStatus->value, 'verification_status' => $verificationStatus->value, 'last_health_check_at' => null, 'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing, 'last_error_message' => 'Dedicated credential is missing.', - ]))->save(); + ])->save(); return $connection->fresh(['credential']); }); } - private function projectConnectionState( - ProviderConnection $connection, - ProviderConnectionType $connectionType, - ProviderConsentStatus $consentStatus, - ProviderVerificationStatus $verificationStatus, - ): array { - $projected = $this->stateProjector->project( - connectionType: $connectionType, - consentStatus: $consentStatus, - verificationStatus: $verificationStatus, - currentStatus: is_string($connection->status) ? $connection->status : null, - ); - - return [ - 'consent_status' => $consentStatus->value, - 'verification_status' => $verificationStatus->value, - 'status' => $projected['status'], - 'health_status' => $projected['health_status'], - ]; - } - private function normalizeConsentStatus( ProviderConsentStatus|string|null $consentStatus, ): ProviderConsentStatus { diff --git a/apps/platform/app/Services/Providers/ProviderConnectionResolver.php b/apps/platform/app/Services/Providers/ProviderConnectionResolver.php index 5e7add63..7409f15a 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionResolver.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionResolver.php @@ -54,7 +54,7 @@ public function validateConnection(Tenant $tenant, string $provider, ProviderCon ); } - if ((string) $connection->status === 'disabled') { + if (! (bool) $connection->is_enabled) { return ProviderConnectionResolution::blocked( ProviderReasonCodes::ProviderConnectionInvalid, 'Provider connection is disabled.', @@ -95,39 +95,30 @@ private function consentBlocker(ProviderConnection $connection): ?ProviderConnec { $consentStatus = $connection->consent_status; - if ($consentStatus instanceof ProviderConsentStatus) { - return match ($consentStatus) { - ProviderConsentStatus::Required => ProviderConnectionResolution::blocked( - ProviderReasonCodes::ProviderConsentMissing, - 'Provider connection requires admin consent before use.', - 'ext.connection_needs_consent', - $connection, - ), - ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked( - ProviderReasonCodes::ProviderConsentFailed, - 'Provider connection consent failed. Retry admin consent before use.', - 'ext.connection_consent_failed', - $connection, - ), - ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked( - ProviderReasonCodes::ProviderConsentRevoked, - 'Provider connection consent was revoked. Grant admin consent again before use.', - 'ext.connection_consent_revoked', - $connection, - ), - default => null, - }; + if (! $consentStatus instanceof ProviderConsentStatus && is_string($consentStatus)) { + $consentStatus = ProviderConsentStatus::tryFrom(trim($consentStatus)); } - if ((string) $connection->status === 'needs_consent') { - return ProviderConnectionResolution::blocked( + return match ($consentStatus) { + ProviderConsentStatus::Required => ProviderConnectionResolution::blocked( ProviderReasonCodes::ProviderConsentMissing, 'Provider connection requires admin consent before use.', 'ext.connection_needs_consent', $connection, - ); - } - - return null; + ), + ProviderConsentStatus::Failed => ProviderConnectionResolution::blocked( + ProviderReasonCodes::ProviderConsentFailed, + 'Provider connection consent failed. Retry admin consent before use.', + 'ext.connection_consent_failed', + $connection, + ), + ProviderConsentStatus::Revoked => ProviderConnectionResolution::blocked( + ProviderReasonCodes::ProviderConsentRevoked, + 'Provider connection consent was revoked. Grant admin consent again before use.', + 'ext.connection_consent_revoked', + $connection, + ), + default => null, + }; } } diff --git a/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php b/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php index 9483e445..c1eeec88 100644 --- a/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php +++ b/apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php @@ -4,55 +4,16 @@ use App\Models\ProviderConnection; use App\Services\Providers\Contracts\HealthResult; -use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderVerificationStatus; final class ProviderConnectionStateProjector { - /** - * @return array{status: string, health_status: string} - */ - public function projectForConnection(ProviderConnection $connection): array - { - return $this->project( - connectionType: $connection->connection_type, - consentStatus: $connection->consent_status, - verificationStatus: $connection->verification_status, - currentStatus: is_string($connection->status) ? $connection->status : null, - ); - } - - /** - * @return array{status: string, health_status: string} - */ - public function project( - ProviderConnectionType|string|null $connectionType, - ProviderConsentStatus|string|null $consentStatus, - ProviderVerificationStatus|string|null $verificationStatus, - ?string $currentStatus = null, - ): array { - $resolvedConnectionType = $this->normalizeConnectionType($connectionType) ?? ProviderConnectionType::Platform; - $resolvedConsentStatus = $this->normalizeConsentStatus($consentStatus) ?? ProviderConsentStatus::Unknown; - $resolvedVerificationStatus = $this->normalizeVerificationStatus($verificationStatus) ?? ProviderVerificationStatus::Unknown; - - $status = $currentStatus === 'disabled' - ? 'disabled' - : $this->projectStatus($resolvedConnectionType, $resolvedConsentStatus, $resolvedVerificationStatus); - - return [ - 'status' => $status, - 'health_status' => $this->projectHealthStatus($resolvedVerificationStatus), - ]; - } - /** * @return array{ * consent_status: ProviderConsentStatus, * verification_status: ProviderVerificationStatus, - * status: string, - * health_status: string, * last_error_reason_code: ?string, * last_error_message: ?string, * consent_error_code: ?string, @@ -62,22 +23,14 @@ public function project( */ public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array { - $currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) - ?? (((string) $connection->status === 'needs_consent') ? ProviderConsentStatus::Required : ProviderConsentStatus::Unknown); + $currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) ?? ProviderConsentStatus::Unknown; $effectiveReasonCode = $result->healthy ? null : $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode); $consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy); - $verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result->healthy, $result->healthStatus); - - $projected = $this->project( - connectionType: $connection->connection_type, - consentStatus: $consentStatus, - verificationStatus: $verificationStatus, - currentStatus: is_string($connection->status) ? $connection->status : null, - ); + $verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result); $consentErrorCode = in_array($consentStatus, [ ProviderConsentStatus::Required, @@ -88,8 +41,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt return [ 'consent_status' => $consentStatus, 'verification_status' => $verificationStatus, - 'status' => $projected['status'], - 'health_status' => $projected['health_status'], 'last_error_reason_code' => $effectiveReasonCode, 'last_error_message' => $result->healthy ? null : $result->message, 'consent_error_code' => $consentErrorCode, @@ -99,19 +50,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt ]; } - private function normalizeConnectionType(ProviderConnectionType|string|null $connectionType): ?ProviderConnectionType - { - if ($connectionType instanceof ProviderConnectionType) { - return $connectionType; - } - - if (! is_string($connectionType)) { - return null; - } - - return ProviderConnectionType::tryFrom(trim($connectionType)); - } - private function normalizeConsentStatus(ProviderConsentStatus|string|null $consentStatus): ?ProviderConsentStatus { if ($consentStatus instanceof ProviderConsentStatus) { @@ -139,41 +77,6 @@ private function normalizeVerificationStatus( return ProviderVerificationStatus::tryFrom(trim($verificationStatus)); } - private function projectStatus( - ProviderConnectionType $connectionType, - ProviderConsentStatus $consentStatus, - ProviderVerificationStatus $verificationStatus, - ): string { - if ($connectionType === ProviderConnectionType::Dedicated && $verificationStatus === ProviderVerificationStatus::Blocked) { - return 'error'; - } - - if ($consentStatus === ProviderConsentStatus::Failed) { - return 'error'; - } - - if ($consentStatus !== ProviderConsentStatus::Granted) { - return 'needs_consent'; - } - - return match ($verificationStatus) { - ProviderVerificationStatus::Blocked, - ProviderVerificationStatus::Error => 'error', - default => 'connected', - }; - } - - private function projectHealthStatus(ProviderVerificationStatus $verificationStatus): string - { - return match ($verificationStatus) { - ProviderVerificationStatus::Healthy => 'ok', - ProviderVerificationStatus::Degraded => 'degraded', - ProviderVerificationStatus::Blocked, - ProviderVerificationStatus::Error => 'down', - default => 'unknown', - }; - } - private function effectiveReasonCodeForVerification( ProviderConsentStatus $currentConsentStatus, ?string $reasonCode, @@ -211,17 +114,12 @@ private function consentStatusAfterVerification( private function verificationStatusAfterVerification( ?string $reasonCode, - bool $healthy, - string $healthStatus, + HealthResult $result, ): ProviderVerificationStatus { - if ($healthy) { + if ($result->healthy) { return ProviderVerificationStatus::Healthy; } - if ($healthStatus === 'degraded' || $reasonCode === ProviderReasonCodes::RateLimited) { - return ProviderVerificationStatus::Degraded; - } - if (in_array($reasonCode, [ ProviderReasonCodes::ProviderConsentMissing, ProviderReasonCodes::ProviderConsentFailed, @@ -238,6 +136,6 @@ private function verificationStatusAfterVerification( return ProviderVerificationStatus::Blocked; } - return ProviderVerificationStatus::Error; + return $this->normalizeVerificationStatus($result->verificationStatus) ?? ProviderVerificationStatus::Error; } } diff --git a/apps/platform/app/Services/Verification/StartVerification.php b/apps/platform/app/Services/Verification/StartVerification.php index 26ca4a4b..20829f79 100644 --- a/apps/platform/app/Services/Verification/StartVerification.php +++ b/apps/platform/app/Services/Verification/StartVerification.php @@ -10,7 +10,6 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Providers\ProviderConnectionResolver; -use App\Services\Providers\ProviderConnectionStateProjector; use App\Services\Providers\ProviderIdentityResolver; use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartResult; @@ -27,7 +26,6 @@ public function __construct( private readonly ProviderOperationStartGate $providers, private readonly ProviderConnectionResolver $connections, private readonly ProviderIdentityResolver $identityResolver, - private readonly ProviderConnectionStateProjector $stateProjector, ) {} /** @@ -126,17 +124,8 @@ public function providerConnectionCheckUsingConnection( ); if ($result->status === 'started') { - $projectedState = $this->stateProjector->project( - connectionType: $connection->connection_type, - consentStatus: $connection->consent_status, - verificationStatus: ProviderVerificationStatus::Pending, - currentStatus: is_string($connection->status) ? $connection->status : null, - ); - $connection->update([ 'verification_status' => ProviderVerificationStatus::Pending, - 'status' => $projectedState['status'], - 'health_status' => $projectedState['health_status'], 'last_error_reason_code' => null, 'last_error_message' => null, ]); diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index 80febb99..399b929d 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -49,8 +49,6 @@ final class BadgeCatalog BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class, BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class, - BadgeDomain::ProviderConnectionStatus->value => Domains\ProviderConnectionStatusBadge::class, - BadgeDomain::ProviderConnectionHealth->value => Domains\ProviderConnectionHealthBadge::class, BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class, BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class, BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class, @@ -157,18 +155,6 @@ public static function normalizeState(mixed $value): ?string return $normalized === '' ? null : $normalized; } - public static function normalizeProviderConnectionStatus(mixed $value): ?string - { - $state = self::normalizeState($value); - - return match ($state) { - 'granted', 'connected' => 'connected', - 'consent_required', 'required', 'needs_admin_consent', 'needs_consent', 'unknown' => 'needs_consent', - 'failed', 'revoked', 'blocked' => 'error', - default => $state, - }; - } - public static function normalizeProviderConsentStatus(mixed $value): ?string { $state = self::normalizeState($value); @@ -195,17 +181,6 @@ public static function normalizeProviderVerificationStatus(mixed $value): ?strin }; } - public static function normalizeProviderConnectionHealth(mixed $value): ?string - { - $state = self::normalizeState($value); - - return match ($state) { - 'healthy' => 'ok', - 'blocked', 'error' => 'down', - default => $state, - }; - } - public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string { $state = self::normalizeState($value); diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index a4c1df51..f65b9444 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -40,8 +40,6 @@ enum BadgeDomain: string case RestoreResultStatus = 'restore_result_status'; case ProviderConsentStatus = 'provider_connection.consent_status'; case ProviderVerificationStatus = 'provider_connection.verification_status'; - case ProviderConnectionStatus = 'provider_connection.status'; - case ProviderConnectionHealth = 'provider_connection.health'; case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status'; case VerificationCheckStatus = 'verification_check_status'; case VerificationCheckSeverity = 'verification_check_severity'; diff --git a/apps/platform/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php b/apps/platform/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php deleted file mode 100644 index 32a1d24f..00000000 --- a/apps/platform/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php +++ /dev/null @@ -1,23 +0,0 @@ - new BadgeSpec('OK', 'success', 'heroicon-m-check-circle'), - 'degraded' => new BadgeSpec('Degraded', 'warning', 'heroicon-m-exclamation-triangle'), - 'down' => new BadgeSpec('Down', 'danger', 'heroicon-m-x-circle'), - 'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'), - default => BadgeSpec::unknown(), - }; - } -} diff --git a/apps/platform/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php b/apps/platform/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php deleted file mode 100644 index b25f2938..00000000 --- a/apps/platform/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php +++ /dev/null @@ -1,23 +0,0 @@ - new BadgeSpec('Connected', 'success', 'heroicon-m-check-circle'), - 'needs_consent' => new BadgeSpec('Needs consent', 'warning', 'heroicon-m-exclamation-triangle'), - 'error' => new BadgeSpec('Error', 'danger', 'heroicon-m-x-circle'), - 'disabled' => new BadgeSpec('Disabled', 'gray', 'heroicon-m-minus-circle'), - default => BadgeSpec::unknown(), - }; - } -} diff --git a/apps/platform/app/Support/System/SystemDirectoryLinks.php b/apps/platform/app/Support/System/SystemDirectoryLinks.php index ffba1c37..68669899 100644 --- a/apps/platform/app/Support/System/SystemDirectoryLinks.php +++ b/apps/platform/app/Support/System/SystemDirectoryLinks.php @@ -30,11 +30,11 @@ public static function tenantsIndex(): string return Tenants::getUrl(panel: 'system'); } - public static function tenantDetail(Tenant|int $tenant): string + public static function tenantDetail(Tenant|string|int $tenant): string { - $tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant; + $tenantRouteKey = self::tenantRouteKey($tenant); - return ViewTenant::getUrl(['tenant' => $tenantId], panel: 'system'); + return ViewTenant::getUrl(['tenant' => $tenantRouteKey], panel: 'system'); } public static function adminWorkspace(Workspace|int $workspace): string @@ -44,10 +44,19 @@ public static function adminWorkspace(Workspace|int $workspace): string return route('filament.admin.resources.workspaces.view', ['record' => $workspaceId]); } - public static function adminTenant(Tenant|int $tenant): string + public static function adminTenant(Tenant|string|int $tenant): string { - $tenantId = $tenant instanceof Tenant ? (int) $tenant->getKey() : (int) $tenant; + $tenantRouteKey = self::tenantRouteKey($tenant); - return route('filament.admin.resources.tenants.view', ['record' => $tenantId]); + return route('filament.admin.resources.tenants.view', ['record' => $tenantRouteKey]); + } + + private static function tenantRouteKey(Tenant|string|int $tenant): string + { + if ($tenant instanceof Tenant) { + return (string) $tenant->getRouteKey(); + } + + return (string) $tenant; } } diff --git a/apps/platform/database/factories/ProviderConnectionFactory.php b/apps/platform/database/factories/ProviderConnectionFactory.php index 4146fa23..2aa6a13b 100644 --- a/apps/platform/database/factories/ProviderConnectionFactory.php +++ b/apps/platform/database/factories/ProviderConnectionFactory.php @@ -47,15 +47,14 @@ public function definition(): array 'entra_tenant_id' => fake()->uuid(), 'display_name' => fake()->company(), 'is_default' => false, + 'is_enabled' => true, 'connection_type' => ProviderConnectionType::Platform->value, - 'status' => 'needs_consent', 'consent_status' => ProviderConsentStatus::Required->value, 'consent_granted_at' => null, 'consent_last_checked_at' => null, 'consent_error_code' => null, 'consent_error_message' => null, 'verification_status' => ProviderVerificationStatus::Unknown->value, - 'health_status' => 'unknown', 'migration_review_required' => false, 'migration_reviewed_at' => null, 'scopes_granted' => [], @@ -83,7 +82,7 @@ public function dedicated(): static public function consentGranted(): static { return $this->state(fn (): array => [ - 'status' => 'connected', + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'consent_granted_at' => now(), 'consent_last_checked_at' => now(), @@ -93,13 +92,19 @@ public function consentGranted(): static public function verifiedHealthy(): static { return $this->state(fn (): array => [ - 'status' => 'connected', + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'consent_granted_at' => now(), 'consent_last_checked_at' => now(), 'verification_status' => ProviderVerificationStatus::Healthy->value, - 'health_status' => 'ok', 'last_health_check_at' => now(), ]); } + + public function disabled(): static + { + return $this->state(fn (): array => [ + 'is_enabled' => false, + ]); + } } diff --git a/apps/platform/database/migrations/2026_04_09_000001_add_is_enabled_to_provider_connections.php b/apps/platform/database/migrations/2026_04_09_000001_add_is_enabled_to_provider_connections.php new file mode 100644 index 00000000..31cdc503 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_09_000001_add_is_enabled_to_provider_connections.php @@ -0,0 +1,27 @@ +boolean('is_enabled')->default(true); + }); + + DB::table('provider_connections') + ->where('status', 'disabled') + ->update(['is_enabled' => false]); + } + + public function down(): void + { + Schema::table('provider_connections', function (Blueprint $table): void { + $table->dropColumn('is_enabled'); + }); + } +}; diff --git a/apps/platform/database/migrations/2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php b/apps/platform/database/migrations/2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php new file mode 100644 index 00000000..34d5be87 --- /dev/null +++ b/apps/platform/database/migrations/2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php @@ -0,0 +1,31 @@ +dropIndex(['tenant_id', 'provider', 'status']); + $table->dropIndex(['tenant_id', 'provider', 'health_status']); + $table->dropIndex(['workspace_id', 'provider', 'status']); + $table->dropIndex(['workspace_id', 'provider', 'health_status']); + $table->dropColumn(['status', 'health_status']); + }); + } + + public function down(): void + { + Schema::table('provider_connections', function (Blueprint $table): void { + $table->string('status')->default('needs_consent'); + $table->string('health_status')->default('unknown'); + $table->index(['tenant_id', 'provider', 'status']); + $table->index(['tenant_id', 'provider', 'health_status']); + $table->index(['workspace_id', 'provider', 'status']); + $table->index(['workspace_id', 'provider', 'health_status']); + }); + } +}; diff --git a/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php b/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php index da99a4c0..9f649cf6 100644 --- a/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php +++ b/apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php @@ -8,18 +8,17 @@ $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; $provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : null; + $lifecycle = is_string($state['lifecycle'] ?? null) ? (string) $state['lifecycle'] : null; + $isEnabled = $state['is_enabled'] ?? null; $consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null; $verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_status'] : null; - $status = is_string($state['status'] ?? null) ? (string) $state['status'] : null; - $healthStatus = is_string($state['health_status'] ?? null) ? (string) $state['health_status'] : null; $lastCheck = is_string($state['last_health_check_at'] ?? null) ? (string) $state['last_health_check_at'] : null; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null; $isMissing = $connectionState === 'missing'; + $lifecycleSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $isEnabled ?? $lifecycle); $consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus); $verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus); - $legacyStatusSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, $status); - $legacyHealthSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, $healthStatus); @endphp
@@ -52,6 +51,14 @@
Provider
{{ $provider ?? 'n/a' }}
+
+
Lifecycle
+
+ + {{ $lifecycleSpec->label }} + +
+
Consent
@@ -76,25 +83,6 @@
Diagnostics
-
-
-
Legacy status
-
- - {{ $legacyStatusSpec->label }} - -
-
-
-
Legacy health
-
- - {{ $legacyHealthSpec->label }} - -
-
-
- @if ($lastErrorReason)
Last error reason: {{ $lastErrorReason }} diff --git a/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php b/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php index d3d3117f..c3bfec44 100644 --- a/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php +++ b/apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php @@ -51,22 +51,43 @@
{{ $connection->provider }} + @if ($connection->display_name) + {{ $connection->display_name }} + @endif + - {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->label }} + {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->label }} - {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->label }} + {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->label }} + + + + {{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->label }} @if ($connection->is_default) Default @endif
+ +
+ Last check: {{ $connection->last_health_check_at?->diffForHumans() ?? 'Never' }} + + @if ($connection->last_error_reason_code) + Last error: {{ $connection->last_error_reason_code }} + @endif +
@endforeach
diff --git a/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php b/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php index 1d4628bc..b1072590 100644 --- a/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php +++ b/apps/platform/tests/Browser/OnboardingDraftRefreshTest.php @@ -39,7 +39,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Browser platform connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $draft = createOnboardingDraft([ @@ -129,7 +129,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Polling verification connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -233,7 +233,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Polling bootstrap connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $verificationRun = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php b/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php index 4eb2c024..56bd5cda 100644 --- a/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php +++ b/apps/platform/tests/Browser/OnboardingDraftVerificationResumeTest.php @@ -40,7 +40,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Previously verified connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([ @@ -50,7 +50,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Current selected connection', 'is_default' => false, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -142,7 +142,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Blocked review connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $verificationRun = OperationRun::factory()->create([ @@ -268,7 +268,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Browser assist connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -405,7 +405,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Browser next-step connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Browser/Spec172DeferredOperatorSurfacesSmokeTest.php b/apps/platform/tests/Browser/Spec172DeferredOperatorSurfacesSmokeTest.php index 5af9dc15..43f34892 100644 --- a/apps/platform/tests/Browser/Spec172DeferredOperatorSurfacesSmokeTest.php +++ b/apps/platform/tests/Browser/Spec172DeferredOperatorSurfacesSmokeTest.php @@ -118,7 +118,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); TenantOnboardingSession::query()->create([ @@ -169,7 +169,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -238,7 +238,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Spec172 completed connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $previousRun = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/AdminConsentCallbackTest.php b/apps/platform/tests/Feature/AdminConsentCallbackTest.php index f6e9736f..937ee2a5 100644 --- a/apps/platform/tests/Feature/AdminConsentCallbackTest.php +++ b/apps/platform/tests/Feature/AdminConsentCallbackTest.php @@ -8,7 +8,7 @@ uses(RefreshDatabase::class); -it('stores successful admin consent on provider connection status', function () { +it('stores successful admin consent on provider connection canonical state', function () { $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-1', 'name' => 'Contoso', @@ -32,7 +32,9 @@ ->first(); expect($connection)->not->toBeNull() - ->and($connection?->status)->toBe('connected') + ->and($connection?->is_enabled)->toBeTrue() + ->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('granted') + ->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown') ->and($connection?->last_error_reason_code)->toBeNull(); $this->assertDatabaseHas('audit_logs', [ @@ -81,11 +83,13 @@ ->first(); expect($connection)->not->toBeNull() - ->and($connection?->status)->toBe('needs_consent') + ->and($connection?->is_enabled)->toBeTrue() + ->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('required') + ->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown') ->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConsentMissing); }); -it('records consent callback errors on provider connection state', function () { +it('records consent callback errors on provider connection canonical state', function () { $tenant = Tenant::factory()->create([ 'tenant_id' => 'tenant-2', 'name' => 'Fabrikam', @@ -105,7 +109,9 @@ ->first(); expect($connection)->not->toBeNull() - ->and($connection?->status)->toBe('error') + ->and($connection?->is_enabled)->toBeTrue() + ->and($connection?->consent_status?->value ?? $connection?->consent_status)->toBe('failed') + ->and($connection?->verification_status?->value ?? $connection?->verification_status)->toBe('unknown') ->and($connection?->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed) ->and($connection?->last_error_message)->toBe('access_denied'); diff --git a/apps/platform/tests/Feature/Audit/OnboardingDraftAuditTest.php b/apps/platform/tests/Feature/Audit/OnboardingDraftAuditTest.php index 22ac00ab..f94acb6b 100644 --- a/apps/platform/tests/Feature/Audit/OnboardingDraftAuditTest.php +++ b/apps/platform/tests/Feature/Audit/OnboardingDraftAuditTest.php @@ -163,7 +163,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Audit Connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $draft = createOnboardingDraft([ diff --git a/apps/platform/tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php b/apps/platform/tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php index e960f615..a1400541 100644 --- a/apps/platform/tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php +++ b/apps/platform/tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php @@ -67,10 +67,9 @@ public function request(string $method, string $path, array $options = []): Grap 'provider' => 'microsoft', 'entra_tenant_id' => 'revoked-audit-tenant-id', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => 'granted', 'verification_status' => 'healthy', - 'status' => 'connected', - 'health_status' => 'ok', ]); $run = OperationRun::factory()->create([ @@ -105,7 +104,6 @@ public function request(string $method, string $path, array $options = []): Grap expect($connection->consent_status?->value ?? $connection->consent_status)->toBe('revoked') ->and($connection->verification_status?->value ?? $connection->verification_status)->toBe('blocked') - ->and($connection->status)->toBe('needs_consent') ->and($run->outcome)->toBe(OperationRunOutcome::Blocked->value) ->and($run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentRevoked); @@ -115,6 +113,8 @@ public function request(string $method, string $path, array $options = []): Grap ->latest('id') ->first(); + $metadata = is_array($log?->metadata ?? null) ? $log->metadata : []; + expect($log)->not->toBeNull() ->and($log?->status)->toBe('failed') ->and($log?->resource_type)->toBe('provider_connection') @@ -125,4 +125,7 @@ public function request(string $method, string $path, array $options = []): Grap ->and($log?->metadata['consent_status'] ?? null)->toBe('revoked') ->and($log?->metadata['verification_status'] ?? null)->toBe('blocked') ->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing); + + expect($metadata)->not->toHaveKey('status') + ->and($metadata)->not->toHaveKey('health_status'); }); diff --git a/apps/platform/tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php b/apps/platform/tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php index e0eb6108..162be523 100644 --- a/apps/platform/tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php +++ b/apps/platform/tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php @@ -64,8 +64,8 @@ public function request(string $method, string $path, array $options = []): Grap 'provider' => 'microsoft', 'entra_tenant_id' => 'verification-audit-tenant-id', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => 'granted', - 'status' => 'connected', ]); $run = OperationRun::factory()->create([ @@ -101,16 +101,20 @@ public function request(string $method, string $path, array $options = []): Grap ->latest('id') ->first(); + $metadata = is_array($log?->metadata ?? null) ? $log->metadata : []; + expect($log)->not->toBeNull() ->and($log?->status)->toBe('success') ->and($log?->resource_type)->toBe('provider_connection') ->and($log?->resource_id)->toBe((string) $connection->getKey()) ->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()) ->and($log?->metadata['connection_type'] ?? null)->toBe('platform') + ->and($log?->metadata['is_enabled'] ?? null)->toBeTrue() ->and($log?->metadata['consent_status'] ?? null)->toBe('granted') ->and($log?->metadata['verification_status'] ?? null)->toBe('healthy') ->and($log?->metadata['effective_client_id'] ?? null)->toBe('platform-client-id') - ->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config') - ->and($log?->metadata['status'] ?? null)->toBe('connected') - ->and($log?->metadata['health_status'] ?? null)->toBe('ok'); + ->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config'); + + expect($metadata)->not->toHaveKey('status') + ->and($metadata)->not->toHaveKey('health_status'); }); diff --git a/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php b/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php index 6a6df187..76a7d3d1 100644 --- a/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php +++ b/apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php @@ -195,6 +195,56 @@ public function request(string $method, string $path, array $options = []): Grap expect($reportCount)->toBe(0); }); +it('skips tenant when the default microsoft provider connection is disabled', function (): void { + [$user, $tenant] = createUserWithTenant(); + + $connection = ensureDefaultProviderConnection($tenant); + $connection->forceFill([ + 'is_enabled' => false, + ])->save(); + + $job = new ScanEntraAdminRolesJob( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + ); + + $job->handle( + buildScanReportService(scanJobGraphMock()), + new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog), + app(\App\Services\OperationRunService::class), + ); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'entra.admin_roles.scan') + ->count())->toBe(0); +}); + +it('skips tenant when the default microsoft provider connection lacks granted consent', function (): void { + [$user, $tenant] = createUserWithTenant(); + + $connection = ensureDefaultProviderConnection($tenant); + $connection->forceFill([ + 'consent_status' => \App\Support\Providers\ProviderConsentStatus::Required->value, + ])->save(); + + $job = new ScanEntraAdminRolesJob( + tenantId: (int) $tenant->getKey(), + workspaceId: (int) $tenant->workspace_id, + ); + + $job->handle( + buildScanReportService(scanJobGraphMock()), + new EntraAdminRolesFindingGenerator(new HighPrivilegeRoleCatalog), + app(\App\Services\OperationRunService::class), + ); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->getKey()) + ->where('type', 'entra.admin_roles.scan') + ->count())->toBe(0); +}); + it('Graph failure marks OperationRun as failed and re-throws', function (): void { [$user, $tenant] = createUserWithTenant(); ensureDefaultProviderConnection($tenant); diff --git a/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php b/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php index ccc99758..51cd2fdd 100644 --- a/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +++ b/apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php @@ -61,11 +61,11 @@ expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found'); expect($table->getColumn('display_name')?->isSearchable())->toBeTrue(); expect($table->getColumn('display_name')?->isSortable())->toBeTrue(); - expect($visibleColumnNames)->toContain('consent_status', 'verification_status'); + expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status'); expect($visibleColumnNames)->not->toContain('status'); expect($visibleColumnNames)->not->toContain('health_status'); - expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue(); - expect($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue(); + expect($table->getColumn('status'))->toBeNull(); + expect($table->getColumn('health_status'))->toBeNull(); expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('migration_review_required'))->not->toBeNull(); diff --git a/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php b/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php index 2df51b66..7c79dc48 100644 --- a/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php +++ b/apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php @@ -41,7 +41,7 @@ $connection = ProviderConnection::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'status' => 'needs_consent', + 'consent_status' => 'required', ]); $tenant->makeCurrent(); @@ -66,7 +66,7 @@ $connection = ProviderConnection::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'status' => 'needs_consent', + 'consent_status' => 'required', ]); $tenant->makeCurrent(); diff --git a/apps/platform/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php b/apps/platform/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php index 39b0dc19..402cb64a 100644 --- a/apps/platform/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php +++ b/apps/platform/tests/Feature/Filament/SettingsCatalogPolicySyncTest.php @@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap 'provider' => 'microsoft', 'entra_tenant_id' => $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/Filament/SettingsCatalogRestoreTest.php b/apps/platform/tests/Feature/Filament/SettingsCatalogRestoreTest.php index 65397cc4..054911c9 100644 --- a/apps/platform/tests/Feature/Filament/SettingsCatalogRestoreTest.php +++ b/apps/platform/tests/Feature/Filament/SettingsCatalogRestoreTest.php @@ -27,7 +27,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant 'tenant_id' => $tenant->id, 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/Filament/TenantSetupTest.php b/apps/platform/tests/Feature/Filament/TenantSetupTest.php index 7f06a57d..87ed73b1 100644 --- a/apps/platform/tests/Feature/Filament/TenantSetupTest.php +++ b/apps/platform/tests/Feature/Filament/TenantSetupTest.php @@ -40,7 +40,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ @@ -106,7 +106,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) diff --git a/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php b/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php index ca5e68e5..17fb31d9 100644 --- a/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php +++ b/apps/platform/tests/Feature/Filament/TenantTruthCleanupSpec179Test.php @@ -33,10 +33,9 @@ 'provider' => 'microsoft', 'display_name' => 'Primary Truth Connection', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Unknown->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user); @@ -82,10 +81,9 @@ 'provider' => 'microsoft', 'display_name' => 'Truth Cleanup Connection', 'is_default' => true, + 'is_enabled' => false, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Blocked->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user); @@ -100,12 +98,14 @@ ->assertSee('Failed') ->assertDontSee('App status') ->assertSee('Truth Cleanup Connection') + ->assertSee('Lifecycle') + ->assertSee('Disabled') ->assertSee('Granted') ->assertSee('Blocked') - ->assertSee('Legacy status') - ->assertSee('Connected') - ->assertSee('Legacy health') - ->assertSee('OK'); + ->assertDontSee('Legacy status') + ->assertDontSee('Connected') + ->assertDontSee('Legacy health') + ->assertDontSee('OK'); }); it('flags tenants that have microsoft connections but no default connection configured', function (): void { @@ -125,10 +125,9 @@ 'provider' => 'microsoft', 'display_name' => 'Fallback Microsoft Connection', 'is_default' => false, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Healthy->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user); @@ -161,10 +160,9 @@ 'provider' => 'microsoft', 'display_name' => 'Blocked Connection', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Blocked->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user); diff --git a/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php b/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php index bbe90153..f1d17be4 100644 --- a/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php +++ b/apps/platform/tests/Feature/Filament/TenantVerificationReportWidgetTest.php @@ -33,7 +33,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ @@ -188,7 +188,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ @@ -253,7 +253,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php b/apps/platform/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php index 1248c8e7..b911c73e 100644 --- a/apps/platform/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php +++ b/apps/platform/tests/Feature/Filament/WindowsUpdateProfilesRestoreTest.php @@ -28,7 +28,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant 'tenant_id' => $tenant->id, 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php b/apps/platform/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php index ec9bb662..d0b32695 100644 --- a/apps/platform/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php +++ b/apps/platform/tests/Feature/Filament/WindowsUpdateRingRestoreTest.php @@ -28,7 +28,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant 'tenant_id' => $tenant->id, 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php b/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php index c561a62d..e1915902 100644 --- a/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php +++ b/apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php @@ -1841,7 +1841,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser 'tenant_id' => (int) $tenant->getKey(), 'workspace_id' => (int) $tenant->workspace_id, 'provider' => 'microsoft', - 'status' => 'connected', + 'is_enabled' => true, 'is_default' => false, ]); diff --git a/apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php b/apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php new file mode 100644 index 00000000..063a9db1 --- /dev/null +++ b/apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php @@ -0,0 +1,51 @@ + 'connected'", + "'status' => 'needs_consent'", + "'status' => 'disabled'", + "'status' => 'error'", + ]; + + $violations = []; + + foreach ($guardedFiles as $relativePath) { + $contents = file_get_contents(repo_path($relativePath)); + + expect($contents)->toBeString(); + + foreach ($forbiddenNeedles as $needle) { + if (! str_contains($contents, $needle)) { + continue; + } + + $violations[] = sprintf('%s contains forbidden legacy provider-state marker "%s".', $relativePath, $needle); + } + } + + expect($violations)->toBe([]); +}); diff --git a/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php b/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php index fe3d350e..2a55689f 100644 --- a/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php +++ b/apps/platform/tests/Feature/Inventory/InventorySyncServiceTest.php @@ -96,7 +96,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array 'provider' => 'microsoft', 'entra_tenant_id' => $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php index daa6ca14..7d566c77 100644 --- a/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +++ b/apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php @@ -145,7 +145,7 @@ 'entra_tenant_id' => $entraTenantId, 'display_name' => 'Platform onboarding connection', 'is_default' => true, - 'status' => 'connected', + 'is_enabled' => true, ]); $run = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/Onboarding/AdminConsentCallbackPlatformConnectionTest.php b/apps/platform/tests/Feature/Onboarding/AdminConsentCallbackPlatformConnectionTest.php index de1ba959..02a9f660 100644 --- a/apps/platform/tests/Feature/Onboarding/AdminConsentCallbackPlatformConnectionTest.php +++ b/apps/platform/tests/Feature/Onboarding/AdminConsentCallbackPlatformConnectionTest.php @@ -30,7 +30,7 @@ ->firstOrFail(); expect($connection->connection_type)->toBe(ProviderConnectionType::Platform) - ->and($connection->status)->toBe('connected') + ->and($connection->is_enabled)->toBeTrue() ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted) ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown) ->and($connection->credential()->exists())->toBeFalse() @@ -55,7 +55,7 @@ ->firstOrFail(); expect($connection->connection_type)->toBe(ProviderConnectionType::Platform) - ->and($connection->status)->toBe('error') + ->and($connection->is_enabled)->toBeTrue() ->and($connection->consent_status)->toBe(ProviderConsentStatus::Failed) ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown) ->and($connection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed) diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingActivationTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingActivationTest.php index 331c5f70..744984cd 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingActivationTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingActivationTest.php @@ -50,7 +50,7 @@ 'entra_tenant_id' => $entraTenantId, 'display_name' => 'Acme connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -195,7 +195,7 @@ 'entra_tenant_id' => $entraTenantId, 'display_name' => 'Blocked by report connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -290,7 +290,7 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Ready connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php index ca16244c..e81a63ec 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingDraftAuthorizationTest.php @@ -453,7 +453,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -531,7 +531,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php index 8c52eb74..9e10e239 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingDraftLifecycleTest.php @@ -346,7 +346,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php index a150b312..130db7b0 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php @@ -62,6 +62,7 @@ ->firstOrFail(); expect($connection->connection_type)->toBe(ProviderConnectionType::Platform) + ->and($connection->is_enabled)->toBeTrue() ->and($connection->consent_status)->toBe(ProviderConsentStatus::Required) ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown) ->and($connection->credential()->exists())->toBeFalse(); diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php index 87cf5df8..f2e606ea 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingVerificationAssistTest.php @@ -81,7 +81,7 @@ function createVerificationAssistDraft( 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Verified connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $selectedConnection = $verifiedConnection; @@ -94,7 +94,7 @@ function createVerificationAssistDraft( 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Current selected connection', 'is_default' => false, - 'status' => 'connected', + 'consent_status' => 'granted', ]); } diff --git a/apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php b/apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php index 55d6ca45..f7711cba 100644 --- a/apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php +++ b/apps/platform/tests/Feature/Onboarding/OnboardingVerificationTest.php @@ -54,7 +54,6 @@ ProviderConnection::query() ->where('tenant_id', (int) $tenant->getKey()) ->update([ - 'status' => 'connected', 'consent_status' => 'granted', 'last_error_reason_code' => null, 'last_error_message' => null, @@ -131,14 +130,14 @@ $tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail(); - $connection = ProviderConnection::factory()->create([ + $connection = ProviderConnection::factory()->dedicated()->create([ 'workspace_id' => (int) $workspace->getKey(), 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => $entraTenantId, 'display_name' => 'Blocked connection', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $component->call('selectProviderConnection', (int) $connection->getKey()); @@ -208,7 +207,6 @@ 'entra_tenant_id' => (string) $tenant->tenant_id, 'display_name' => 'Verified connection', 'is_default' => true, - 'status' => 'connected', ]); $draft = createOnboardingDraft([ @@ -278,7 +276,6 @@ 'entra_tenant_id' => $entraTenantId, 'display_name' => 'Contoso platform connection', 'is_default' => true, - 'status' => 'connected', ]); $run = OperationRun::factory()->create([ @@ -361,7 +358,6 @@ 'workspace_id' => (int) $workspace->getKey(), 'tenant_id' => (int) $tenant->getKey(), 'is_default' => true, - 'status' => 'connected', 'consent_status' => 'granted', ]); @@ -537,7 +533,6 @@ 'entra_tenant_id' => (string) $otherTenant->tenant_id, 'display_name' => 'Forged verification connection', 'is_default' => true, - 'status' => 'connected', ]); $otherDraft = TenantOnboardingSession::query()->create([ diff --git a/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php b/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php index 93e63526..a30b3519 100644 --- a/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php +++ b/apps/platform/tests/Feature/Operations/QueuedExecutionContractMatrixTest.php @@ -33,7 +33,7 @@ function runQueuedContractMatrixJobThroughMiddleware(object $job, Closure $termi 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = app(OperationRunService::class)->ensureRun( @@ -205,7 +205,7 @@ function () use (&$terminalInvoked): string { 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); $result = app(ProviderOperationStartGate::class)->start( diff --git a/apps/platform/tests/Feature/PolicySyncServiceReportTest.php b/apps/platform/tests/Feature/PolicySyncServiceReportTest.php index ddf63da1..69c78ea8 100644 --- a/apps/platform/tests/Feature/PolicySyncServiceReportTest.php +++ b/apps/platform/tests/Feature/PolicySyncServiceReportTest.php @@ -25,7 +25,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySyncReport(array $attribut 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', 'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()), ]); diff --git a/apps/platform/tests/Feature/PolicySyncServiceTest.php b/apps/platform/tests/Feature/PolicySyncServiceTest.php index ccdb4bda..f9f797a8 100644 --- a/apps/platform/tests/Feature/PolicySyncServiceTest.php +++ b/apps/platform/tests/Feature/PolicySyncServiceTest.php @@ -24,7 +24,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', 'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()), ]); diff --git a/apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php b/apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php index 002baa16..ab03a234 100644 --- a/apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/DisabledActionsTooltipTest.php @@ -15,7 +15,7 @@ $connection = ProviderConnection::factory()->create([ 'workspace_id' => (int) $tenant->workspace_id, 'tenant_id' => (int) $tenant->getKey(), - 'status' => 'disabled', + 'is_enabled' => false, 'provider' => 'microsoft', ]); diff --git a/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php b/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php index 903c7bcc..648795f5 100644 --- a/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php @@ -31,6 +31,9 @@ expect($created)->not->toBeNull(); expect($created?->provider)->toBe('microsoft'); + expect($created?->is_enabled)->toBeTrue(); + expect($created?->consent_status?->value ?? $created?->consent_status)->toBe('required'); + expect($created?->verification_status?->value ?? $created?->verification_status)->toBe('unknown'); $listComponent = Livewire::test(ListProviderConnections::class); $providerFilter = $listComponent->instance()->getTable()->getFilters()['provider'] ?? null; diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php index d3f4d834..17a3103f 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionCutoverSpec081Test.php @@ -41,7 +41,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $result = app(ProviderOperationStartGate::class)->start( @@ -67,7 +67,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $first->getKey(), @@ -77,7 +77,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $second->getKey(), diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php index b31c226e..03db9c53 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php @@ -1,14 +1,18 @@ actingAs($user); @@ -19,8 +23,9 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'disabled', - 'health_status' => 'down', + 'is_enabled' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Error->value, 'last_health_check_at' => now(), 'last_error_reason_code' => 'provider_auth_failed', 'last_error_message' => 'Some failure', @@ -30,15 +35,27 @@ ->callAction('enable_connection'); $connection->refresh(); + $audit = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'provider_connection.enabled') + ->latest('id') + ->first(); - expect($connection->status)->toBe('needs_consent'); - expect($connection->health_status)->toBe('unknown'); - expect($connection->last_health_check_at)->toBeNull(); - expect($connection->last_error_reason_code)->toBeNull(); - expect($connection->last_error_message)->toBeNull(); + expect($connection->is_enabled)->toBeTrue() + ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted) + ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Blocked) + ->and($connection->last_health_check_at)->toBeNull() + ->and($connection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderCredentialMissing) + ->and($connection->last_error_message)->toBe('Provider connection credentials are missing.'); + + expect($audit)->not->toBeNull() + ->and($audit?->metadata['from_lifecycle'] ?? null)->toBe('disabled') + ->and($audit?->metadata['to_lifecycle'] ?? null)->toBe('enabled') + ->and($audit?->metadata['verification_status'] ?? null)->toBe(ProviderVerificationStatus::Blocked->value) + ->and($audit?->metadata['credentials_present'] ?? null)->toBeFalse(); }); -it('enables a disabled connection and sets connected when credentials are present', function (): void { +it('enables a disabled connection and resets verification when credentials are present', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); $this->actingAs($user); @@ -49,8 +66,9 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'disabled', - 'health_status' => 'down', + 'is_enabled' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Error->value, ]); ProviderCredential::factory()->create([ @@ -65,9 +83,56 @@ ->callAction('enable_connection'); $connection->refresh(); + $audit = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'provider_connection.enabled') + ->latest('id') + ->first(); - expect($connection->status)->toBe('connected'); - expect($connection->health_status)->toBe('unknown'); + expect($connection->is_enabled)->toBeTrue() + ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted) + ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown); + + expect($audit)->not->toBeNull() + ->and($audit?->metadata['from_lifecycle'] ?? null)->toBe('disabled') + ->and($audit?->metadata['to_lifecycle'] ?? null)->toBe('enabled') + ->and($audit?->metadata['verification_status'] ?? null)->toBe(ProviderVerificationStatus::Unknown->value) + ->and($audit?->metadata['credentials_present'] ?? null)->toBeTrue(); +}); + +it('disables an enabled connection without changing consent or verification truth', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'is_enabled' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->callAction('disable_connection'); + + $connection->refresh(); + $audit = AuditLog::query() + ->where('tenant_id', (int) $tenant->getKey()) + ->where('action', 'provider_connection.disabled') + ->latest('id') + ->first(); + + expect($connection->is_enabled)->toBeFalse() + ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted) + ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Healthy); + + expect($audit)->not->toBeNull() + ->and($audit?->metadata['from_lifecycle'] ?? null)->toBe('enabled') + ->and($audit?->metadata['to_lifecycle'] ?? null)->toBe('disabled'); }); it('shows a link to the last connection check run when present', function (): void { diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php index 4b8b4871..fdbe3644 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php @@ -1,5 +1,7 @@ instance(GraphClientInterface::class, new class implements GraphClientInterface @@ -50,8 +54,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', - 'health_status' => 'unknown', + 'is_enabled' => true, + 'verification_status' => ProviderVerificationStatus::Unknown->value, ]); ProviderCredential::factory()->create([ @@ -91,11 +95,12 @@ public function request(string $method, string $path, array $options = []): Grap $connection->refresh(); $run->refresh(); - expect($connection->status)->toBe('connected'); - expect($connection->health_status)->toBe('ok'); - expect($connection->last_health_check_at)->not->toBeNull(); - expect($connection->last_error_reason_code)->toBeNull(); - expect($connection->last_error_message)->toBeNull(); + expect($connection->is_enabled)->toBeTrue() + ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted) + ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Healthy) + ->and($connection->last_health_check_at)->not->toBeNull() + ->and($connection->last_error_reason_code)->toBeNull() + ->and($connection->last_error_message)->toBeNull(); expect($run->status)->toBe('completed'); expect($run->outcome)->toBe('succeeded'); @@ -155,8 +160,9 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', - 'health_status' => 'ok', + 'is_enabled' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, ]); ProviderCredential::factory()->create([ @@ -193,13 +199,17 @@ public function request(string $method, string $path, array $options = []): Grap $job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class)); + $connection->refresh(); $run->refresh(); + expect($connection->consent_status)->toBe(ProviderConsentStatus::Revoked) + ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Blocked); + expect($run->status)->toBe(OperationRunStatus::Completed->value); expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value); $context = is_array($run->context ?? null) ? $run->context : []; - expect($context['reason_code'] ?? null)->toBe('provider_consent_missing'); + expect($context['reason_code'] ?? null)->toBe('provider_consent_revoked'); $nextSteps = $context['next_steps'] ?? null; expect($nextSteps)->toBeArray(); @@ -263,8 +273,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', - 'health_status' => 'unknown', + 'is_enabled' => true, + 'verification_status' => ProviderVerificationStatus::Unknown->value, ]); ProviderCredential::factory()->create([ @@ -354,8 +364,8 @@ public function request(string $method, string $path, array $options = []): Grap 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', - 'health_status' => 'unknown', + 'is_enabled' => true, + 'verification_status' => ProviderVerificationStatus::Unknown->value, ]); ProviderCredential::factory()->create([ @@ -395,9 +405,9 @@ public function request(string $method, string $path, array $options = []): Grap $connection->refresh(); $run->refresh(); - expect($connection->status)->toBe('error'); - expect($connection->health_status)->toBe('down'); - expect($connection->last_error_reason_code)->toBe('provider_auth_failed'); + expect($connection->consent_status)->toBe(ProviderConsentStatus::Granted) + ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Error) + ->and($connection->last_error_reason_code)->toBe('provider_auth_failed'); expect((string) $connection->last_error_message) ->not->toContain('Authorization') ->not->toContain('Bearer ') diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php index 5a850227..efa0df1f 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php @@ -32,7 +32,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'is_enabled' => true, ]); Livewire::test(ListProviderConnections::class) @@ -78,7 +78,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'is_enabled' => true, ]); $component = Livewire::test(ListProviderConnections::class); @@ -107,7 +107,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'is_enabled' => true, ]); Livewire::test(ListProviderConnections::class) diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionMigrationClassificationTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionMigrationClassificationTest.php index 53cac89b..73ca65ab 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionMigrationClassificationTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionMigrationClassificationTest.php @@ -113,9 +113,9 @@ expect($hybridConnection->connection_type->value)->toBe('dedicated') ->and($hybridConnection->migration_review_required)->toBeTrue() ->and($hybridConnection->verification_status->value)->toBe('blocked') - ->and($hybridConnection->status)->toBe('error') - ->and($hybridConnection->health_status)->toBe('down') + ->and($hybridConnection->is_enabled)->toBeTrue() ->and($hybridConnection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConnectionReviewRequired) + ->and($hybridConnection->last_error_message)->toBe('Legacy provider connection requires explicit migration review.') ->and($hybridConnection->metadata)->toMatchArray([ 'legacy_identity_review_required' => true, 'legacy_identity_result' => 'dedicated', diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php index 16628f00..20976e0a 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php @@ -22,10 +22,9 @@ 'display_name' => 'Contoso', 'provider' => 'microsoft', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Degraded->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user); @@ -42,11 +41,11 @@ ->values() ->all(); - expect($visibleColumnNames)->toContain('consent_status', 'verification_status') + expect($visibleColumnNames)->toContain('is_enabled', 'consent_status', 'verification_status') ->and($visibleColumnNames)->not->toContain('status') ->and($visibleColumnNames)->not->toContain('health_status') - ->and($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue() - ->and($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue(); + ->and($table->getColumn('status'))->toBeNull() + ->and($table->getColumn('health_status'))->toBeNull(); }); it('separates current state from diagnostics on the provider connection view page', function (): void { @@ -58,10 +57,9 @@ 'display_name' => 'Truthful Connection', 'provider' => 'microsoft', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Degraded->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user) @@ -69,15 +67,14 @@ ->assertOk() ->assertSeeInOrder([ 'Current state', + 'Lifecycle', + 'Enabled', 'Consent', 'Granted', 'Verification', 'Degraded', 'Diagnostics', - 'Legacy status', - 'Connected', - 'Legacy health', - 'OK', + 'Last error reason', ]); }); @@ -90,10 +87,9 @@ 'display_name' => 'Editable Truthful Connection', 'provider' => 'microsoft', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Blocked->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user) @@ -101,15 +97,14 @@ ->assertOk() ->assertSeeInOrder([ 'Current state', + 'Lifecycle', + 'Enabled', 'Consent', 'Granted', 'Verification', 'Blocked', 'Diagnostics', - 'Legacy status', - 'Connected', - 'Legacy health', - 'OK', + 'Last error reason', ]); }); @@ -122,10 +117,9 @@ 'display_name' => 'Unknown Verification Connection', 'provider' => 'microsoft', 'is_default' => true, + 'is_enabled' => true, 'consent_status' => ProviderConsentStatus::Granted->value, 'verification_status' => ProviderVerificationStatus::Unknown->value, - 'status' => 'connected', - 'health_status' => 'ok', ]); $this->actingAs($user); @@ -145,3 +139,39 @@ ->assertSee('Unknown') ->assertDontSee('Ready'); }); + +it('shows lifecycle independently from consent and verification when a connection is disabled', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false); + + $connection = ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'display_name' => 'Disabled But Consented', + 'provider' => 'microsoft', + 'is_default' => true, + 'is_enabled' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, + ]); + + $this->actingAs($user); + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(ListProviderConnections::class) + ->assertCanSeeTableRecords([$connection]) + ->assertSee('Disabled') + ->assertSee('Granted') + ->assertSee('Healthy') + ->assertDontSee('Connected') + ->assertDontSee('OK'); + + $this->get(ProviderConnectionResource::getUrl('view', ['record' => $connection], tenant: $tenant)) + ->assertOk() + ->assertSee('Disabled') + ->assertSee('Granted') + ->assertSee('Healthy') + ->assertDontSee('Connected') + ->assertDontSee('OK'); +}); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php index 319f5637..5ffbd425 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php @@ -15,7 +15,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'display_name' => 'Spec081 Connection', 'provider' => 'microsoft', - 'status' => 'connected', + 'is_enabled' => true, 'migration_review_required' => true, 'metadata' => [ 'legacy_identity_classification_source' => 'tenantpilot:provider-connections:classify', @@ -38,8 +38,13 @@ $this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin')) ->assertOk() ->assertSee('Spec081 Connection') + ->assertSee('Lifecycle') + ->assertSee('Enabled') + ->assertSee('Verification') ->assertSee('Migration review') - ->assertSee('Review required'); + ->assertSee('Review required') + ->assertDontSee('Diagnostic status') + ->assertDontSee('Diagnostic health'); }); Bus::assertNothingDispatched(); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php index 43f6f72d..9fd7de15 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php @@ -44,7 +44,7 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array 'workspace_id' => (int) $tenant->workspace_id, 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', 'entra_tenant_id' => $tenantId, ]); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php index 5ada9d0e..8a650088 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php @@ -25,7 +25,7 @@ $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', - 'status' => 'connected', + 'consent_status' => 'granted', 'is_default' => true, ]); diff --git a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php index 6993b90f..0fb57612 100644 --- a/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/ProviderOperationConcurrencyTest.php @@ -33,7 +33,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); $component = Livewire::test(ListProviderConnections::class); @@ -86,7 +86,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); $component = Livewire::test(ListProviderConnections::class); @@ -130,7 +130,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); $component = Livewire::test(ListProviderConnections::class); diff --git a/apps/platform/tests/Feature/ProviderConnections/RequiredFiltersTest.php b/apps/platform/tests/Feature/ProviderConnections/RequiredFiltersTest.php index 8676e6f9..5c091072 100644 --- a/apps/platform/tests/Feature/ProviderConnections/RequiredFiltersTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/RequiredFiltersTest.php @@ -35,9 +35,9 @@ $filters = $component->instance()->getTable()->getFilters(); $filterNames = array_keys($filters); - expect($filterNames)->toContain('tenant', 'provider', 'consent_status', 'verification_status', 'status', 'health_status', 'default_only'); - expect($filters['status']->getLabel())->toBe('Diagnostic status'); - expect($filters['health_status']->getLabel())->toBe('Diagnostic health'); + expect($filterNames)->toContain('tenant', 'provider', 'consent_status', 'verification_status', 'is_enabled', 'default_only'); + expect($filterNames)->not->toContain('status', 'health_status'); + expect($filters['is_enabled']->getLabel())->toBe('Lifecycle'); $component ->set('tableFilters.default_only.isActive', true) diff --git a/apps/platform/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php b/apps/platform/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php index 7f162fa1..68929c88 100644 --- a/apps/platform/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php +++ b/apps/platform/tests/Feature/ProviderConnections/TenantFilterOverrideTest.php @@ -80,7 +80,8 @@ 'tenant_id' => (int) $tenantA->getKey(), 'display_name' => 'A Connected', 'provider' => 'microsoft', - 'status' => 'connected', + 'consent_status' => 'granted', + 'verification_status' => 'healthy', ]); $tenantBConnected = ProviderConnection::factory()->create([ @@ -88,7 +89,8 @@ 'tenant_id' => (int) $tenantB->getKey(), 'display_name' => 'B Connected', 'provider' => 'microsoft', - 'status' => 'connected', + 'consent_status' => 'granted', + 'verification_status' => 'healthy', ]); $tenantBFailed = ProviderConnection::factory()->create([ @@ -96,7 +98,8 @@ 'tenant_id' => (int) $tenantB->getKey(), 'display_name' => 'B Failed', 'provider' => 'microsoft', - 'status' => 'error', + 'consent_status' => 'granted', + 'verification_status' => 'error', ]); $this->actingAs($user); @@ -104,7 +107,7 @@ Livewire::withQueryParams([ 'tenant_id' => (string) $tenantB->external_id, ])->test(ListProviderConnections::class) - ->filterTable('status', 'connected') + ->filterTable('verification_status', 'healthy') ->assertCanSeeTableRecords([$tenantBConnected]) ->assertCanNotSeeTableRecords([$tenantAConnection, $tenantBFailed]); }); diff --git a/apps/platform/tests/Feature/Providers/MicrosoftGraphOptionsResolverTest.php b/apps/platform/tests/Feature/Providers/MicrosoftGraphOptionsResolverTest.php index 85bb0888..939e6cad 100644 --- a/apps/platform/tests/Feature/Providers/MicrosoftGraphOptionsResolverTest.php +++ b/apps/platform/tests/Feature/Providers/MicrosoftGraphOptionsResolverTest.php @@ -36,7 +36,7 @@ 'workspace_id' => $tenant->workspace_id, 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'needs_consent', + 'consent_status' => 'required', ]); $resolver = app(MicrosoftGraphOptionsResolver::class); @@ -61,7 +61,6 @@ 'provider' => 'microsoft', 'entra_tenant_id' => 'entra-tenant-id', 'is_default' => true, - 'status' => 'connected', ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php b/apps/platform/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php index 6330d4bd..c76afd01 100644 --- a/apps/platform/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php +++ b/apps/platform/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php @@ -17,7 +17,7 @@ $connection = ProviderConnection::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'status' => 'disabled', + 'is_enabled' => false, ]); $this->get('/admin/provider-connections/'.$connection->getKey().'/edit') @@ -33,7 +33,7 @@ $connection = ProviderConnection::factory()->create([ 'tenant_id' => $tenant->getKey(), - 'status' => 'disabled', + 'is_enabled' => false, ]); Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) diff --git a/apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php b/apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php index 76e5dfaf..c3285f94 100644 --- a/apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php +++ b/apps/platform/tests/Feature/Rbac/OnboardingWizardUiEnforcementTest.php @@ -186,7 +186,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ @@ -260,7 +260,7 @@ 'provider' => 'microsoft', 'entra_tenant_id' => (string) $tenant->tenant_id, 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); $run = OperationRun::factory()->create([ diff --git a/apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php b/apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php index 31640f47..b4535dcb 100644 --- a/apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php +++ b/apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php @@ -3,9 +3,13 @@ declare(strict_types=1); use App\Models\PlatformUser; +use App\Models\ProviderConnection; use App\Models\Tenant; use App\Models\Workspace; use App\Support\Auth\PlatformCapabilities; +use App\Support\Providers\ProviderConsentStatus; +use App\Support\Providers\ProviderVerificationStatus; +use App\Support\System\SystemDirectoryLinks; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -23,19 +27,65 @@ ->assertForbidden(); }); -it('lists tenants in the system directory', function () { +it('lists tenants in the system directory with canonical health rollups from default microsoft connections only', function () { $workspace = Workspace::factory()->create(['name' => 'Directory Workspace']); - Tenant::factory()->create([ + $criticalTenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), - 'name' => 'Contoso', + 'name' => 'A Critical Tenant', 'status' => Tenant::STATUS_ACTIVE, ]); - Tenant::factory()->create([ + $warningTenant = Tenant::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), - 'name' => 'Fabrikam', - 'status' => Tenant::STATUS_ONBOARDING, + 'name' => 'B Warning Tenant', + 'status' => Tenant::STATUS_ACTIVE, + ]); + + $healthyTenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'C Healthy Tenant', + 'status' => Tenant::STATUS_ACTIVE, + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $criticalTenant->getKey(), + 'provider' => 'microsoft', + 'is_default' => true, + 'is_enabled' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Blocked->value, + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $warningTenant->getKey(), + 'provider' => 'microsoft', + 'is_default' => true, + 'is_enabled' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Degraded->value, + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $healthyTenant->getKey(), + 'provider' => 'microsoft', + 'is_default' => true, + 'is_enabled' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $healthyTenant->getKey(), + 'provider' => 'microsoft', + 'is_default' => false, + 'is_enabled' => true, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Blocked->value, ]); $platformUser = PlatformUser::factory()->create([ @@ -49,6 +99,52 @@ $this->actingAs($platformUser, 'platform') ->get('/system/directory/tenants') ->assertSuccessful() - ->assertSee('Contoso') - ->assertSee('Fabrikam'); + ->assertSeeInOrder([ + 'A Critical Tenant', + 'Critical', + 'B Warning Tenant', + 'Warn', + 'C Healthy Tenant', + 'OK', + ]); +}); + +it('renders system tenant detail rows with lifecycle, consent, and verification only', function () { + $workspace = Workspace::factory()->create(['name' => 'Directory Workspace']); + + $tenant = Tenant::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'name' => 'Directory Detail Tenant', + 'status' => Tenant::STATUS_ACTIVE, + ]); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $workspace->getKey(), + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Disabled Default Connection', + 'is_default' => true, + 'is_enabled' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, + ]); + + $platformUser = PlatformUser::factory()->create([ + 'capabilities' => [ + PlatformCapabilities::ACCESS_SYSTEM_PANEL, + PlatformCapabilities::DIRECTORY_VIEW, + ], + 'is_active' => true, + ]); + + $this->actingAs($platformUser, 'platform') + ->get(SystemDirectoryLinks::tenantDetail($tenant)) + ->assertSuccessful() + ->assertSee('Connectivity signals') + ->assertSee('Disabled Default Connection') + ->assertSee('Disabled') + ->assertSee('Granted') + ->assertSee('Healthy') + ->assertDontSee('Connected') + ->assertDontSee('Legacy health'); }); diff --git a/apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php b/apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php index 2513320a..8e4fe5f2 100644 --- a/apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php +++ b/apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php @@ -3,6 +3,9 @@ declare(strict_types=1); use App\Filament\Resources\TenantResource; +use App\Models\ProviderConnection; +use App\Support\Providers\ProviderConsentStatus; +use App\Support\Providers\ProviderVerificationStatus; it('renders provider connections CTA with canonical tenantless URL on tenant detail page', function (): void { [$user, $tenant] = createUserWithTenant(role: 'owner'); @@ -34,3 +37,37 @@ ->assertOk() ->assertSee('/admin/provider-connections?tenant_id='.(string) $tenant->external_id, false); }); + +it('renders the tenant provider summary with lifecycle, consent, and verification only', function (): void { + $tenant = \App\Models\Tenant::factory()->active()->create(); + [$user, $tenant] = createUserWithTenant( + tenant: $tenant, + role: 'owner', + ensureDefaultMicrosoftProviderConnection: false, + ); + + ProviderConnection::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'tenant_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'display_name' => 'Canonical Summary Connection', + 'is_default' => true, + 'is_enabled' => false, + 'consent_status' => ProviderConsentStatus::Granted->value, + 'verification_status' => ProviderVerificationStatus::Healthy->value, + ]); + + $this->actingAs($user) + ->get(TenantResource::getUrl('view', ['record' => $tenant], tenant: $tenant)) + ->assertOk() + ->assertSee('Provider connection') + ->assertSee('Canonical Summary Connection') + ->assertSee('Lifecycle') + ->assertSee('Disabled') + ->assertSee('Consent') + ->assertSee('Granted') + ->assertSee('Verification') + ->assertSee('Healthy') + ->assertDontSee('Connected') + ->assertDontSee('Legacy health'); +}); diff --git a/apps/platform/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php b/apps/platform/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php index 842f3677..ac627cef 100644 --- a/apps/platform/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php +++ b/apps/platform/tests/Feature/Verification/ProviderExecutionReauthorizationTest.php @@ -36,7 +36,7 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); Livewire::test(ListProviderConnections::class) @@ -69,7 +69,7 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); Livewire::test(ListProviderConnections::class) @@ -120,7 +120,7 @@ function (ProviderConnectionHealthCheckJob $job) use (&$terminalInvoked): mixed 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); Livewire::test(ListProviderConnections::class) diff --git a/apps/platform/tests/Feature/Verification/VerificationAuthorizationTest.php b/apps/platform/tests/Feature/Verification/VerificationAuthorizationTest.php index 01d3bc8e..69ab87b6 100644 --- a/apps/platform/tests/Feature/Verification/VerificationAuthorizationTest.php +++ b/apps/platform/tests/Feature/Verification/VerificationAuthorizationTest.php @@ -95,7 +95,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), diff --git a/apps/platform/tests/Feature/Verification/VerificationStartAfterCompletionTest.php b/apps/platform/tests/Feature/Verification/VerificationStartAfterCompletionTest.php index 24db4990..dce54207 100644 --- a/apps/platform/tests/Feature/Verification/VerificationStartAfterCompletionTest.php +++ b/apps/platform/tests/Feature/Verification/VerificationStartAfterCompletionTest.php @@ -26,7 +26,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), diff --git a/apps/platform/tests/Feature/Verification/VerificationStartDedupeTest.php b/apps/platform/tests/Feature/Verification/VerificationStartDedupeTest.php index fc0f1aa6..1f6c7cbb 100644 --- a/apps/platform/tests/Feature/Verification/VerificationStartDedupeTest.php +++ b/apps/platform/tests/Feature/Verification/VerificationStartDedupeTest.php @@ -23,7 +23,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), @@ -70,7 +70,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => fake()->uuid(), - 'status' => 'connected', + 'consent_status' => 'granted', 'is_default' => true, ]); ProviderCredential::factory()->create([ diff --git a/apps/platform/tests/Pest.php b/apps/platform/tests/Pest.php index 998d2aa6..bd4198a4 100644 --- a/apps/platform/tests/Pest.php +++ b/apps/platform/tests/Pest.php @@ -584,8 +584,8 @@ function ensureDefaultProviderConnection( $updates['is_default'] = true; } - if ($connection->status !== 'connected') { - $updates['status'] = 'connected'; + if (! $connection->is_enabled) { + $updates['is_enabled'] = true; } if ($currentConsentStatus !== ProviderConsentStatus::Granted->value) { @@ -596,10 +596,6 @@ function ensureDefaultProviderConnection( $updates['verification_status'] = ProviderVerificationStatus::Healthy->value; } - if ($connection->health_status !== 'ok') { - $updates['health_status'] = 'ok'; - } - if ($entraTenantId === '') { $updates['entra_tenant_id'] = (string) ($tenant->tenant_id ?? fake()->uuid()); } diff --git a/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php b/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php index c681ff0f..c365357e 100644 --- a/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php +++ b/apps/platform/tests/Unit/Badges/BadgeCatalogTest.php @@ -39,3 +39,12 @@ expect(BadgeCatalog::mapper(BadgeDomain::InventoryCoverageState))->not->toBeNull() ->and(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label)->toBe('Failed'); }); + +it('keeps lifecycle badges on the shared boolean-enabled domain instead of legacy provider domains', function (): void { + $domainValues = collect(BadgeDomain::cases()) + ->map(fn (BadgeDomain $domain): string => $domain->value) + ->all(); + + expect(BadgeCatalog::mapper(BadgeDomain::BooleanEnabled))->not->toBeNull() + ->and($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health'); +}); diff --git a/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php b/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php index be059339..498c6c21 100644 --- a/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php +++ b/apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php @@ -39,40 +39,16 @@ $blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked'); expect($blocked->color)->toBe('danger'); expect($blocked->label)->toBe('Blocked'); -}); - -it('maps provider connection legacy status safely', function (): void { - $connected = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'connected'); - expect($connected->color)->toBe('success'); - expect($connected->label)->toBe('Connected'); - - $needsConsent = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'needs_consent'); - expect($needsConsent->color)->toBe('warning'); - expect($needsConsent->label)->toBe('Needs consent'); - - $error = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'error'); - expect($error->color)->toBe('danger'); - expect($error->label)->toBe('Error'); - - $disabled = BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'disabled'); - expect($disabled->color)->toBe('gray'); - expect($disabled->label)->toBe('Disabled'); -}); - -it('maps provider connection legacy health safely', function (): void { - $ok = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'ok'); - expect($ok->color)->toBe('success'); - expect($ok->label)->toBe('OK'); - - $degraded = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'degraded'); + + $degraded = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'degraded'); expect($degraded->color)->toBe('warning'); expect($degraded->label)->toBe('Degraded'); - - $down = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'down'); - expect($down->color)->toBe('danger'); - expect($down->label)->toBe('Down'); - - $unknown = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown'); - expect($unknown->color)->toBe('gray'); - expect($unknown->label)->toBe('Unknown'); +}); + +it('does not expose legacy provider status badge domains anymore', function (): void { + $domainValues = collect(BadgeDomain::cases()) + ->map(fn (BadgeDomain $domain): string => $domain->value) + ->all(); + + expect($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health'); }); diff --git a/apps/platform/tests/Unit/PolicySnapshotServiceTest.php b/apps/platform/tests/Unit/PolicySnapshotServiceTest.php index ad8e1772..55ee1e24 100644 --- a/apps/platform/tests/Unit/PolicySnapshotServiceTest.php +++ b/apps/platform/tests/Unit/PolicySnapshotServiceTest.php @@ -24,7 +24,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySnapshot(string $tenantIde 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', 'entra_tenant_id' => $tenantIdentifier, ]); diff --git a/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php b/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php index 21dd46c1..9b030de0 100644 --- a/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php @@ -5,18 +5,17 @@ use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeDomain; -it('normalizes provider connection status badges from consent and verification semantics', function (): void { - expect(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'required')->label)->toBe('Needs consent') - ->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'revoked')->label)->toBe('Error') - ->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'blocked')->label)->toBe('Error') - ->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'connected')->label)->toBe('Connected'); +it('normalizes provider consent aliases through the canonical consent badge domain', function (): void { + expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'needs_consent')->label)->toBe('Required') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'connected')->label)->toBe('Granted') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'error')->label)->toBe('Failed'); }); -it('normalizes provider connection health badges from verification semantics', function (): void { - expect(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'healthy')->label)->toBe('OK') - ->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'blocked')->label)->toBe('Down') - ->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'error')->label)->toBe('Down') - ->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown')->label)->toBe('Unknown'); +it('normalizes provider verification aliases through the canonical verification badge domain', function (): void { + expect(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'ok')->label)->toBe('Healthy') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'warning')->label)->toBe('Degraded') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'failed')->label)->toBe('Error') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked'); }); it('maps managed-tenant onboarding verification badge aliases consistently', function (): void { diff --git a/apps/platform/tests/Unit/Providers/ProviderConnectionClassifierTest.php b/apps/platform/tests/Unit/Providers/ProviderConnectionClassifierTest.php index 2d2d83ba..73d8f798 100644 --- a/apps/platform/tests/Unit/Providers/ProviderConnectionClassifierTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderConnectionClassifierTest.php @@ -92,7 +92,6 @@ 'entra_tenant_id' => 'hybrid-tenant-id', 'consent_status' => 'granted', 'verification_status' => 'healthy', - 'status' => 'connected', ]); ProviderCredential::factory()->create([ @@ -119,8 +118,8 @@ ->and($result->signals)->toMatchArray([ 'tenant_client_id' => 'legacy-tenant-client-id', 'credential_client_id' => 'dedicated-client-id', + 'is_enabled' => true, 'consent_status' => 'granted', 'verification_status' => 'healthy', - 'status' => 'connected', ]); }); diff --git a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php index 23f2a3b0..390a1ac8 100644 --- a/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php +++ b/apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php @@ -19,7 +19,7 @@ 'tenant_id' => $tenant->getKey(), 'provider' => 'microsoft', 'entra_tenant_id' => 'entra-tenant-id', - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), @@ -62,7 +62,7 @@ $tenant = Tenant::factory()->create(); $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ 'tenant_id' => $tenant->getKey(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), @@ -99,7 +99,7 @@ $tenant = Tenant::factory()->create(); $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ 'tenant_id' => $tenant->getKey(), - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), diff --git a/apps/platform/tests/Unit/RbacOnboardingServiceTest.php b/apps/platform/tests/Unit/RbacOnboardingServiceTest.php index 5af4fb20..d9d3c3be 100644 --- a/apps/platform/tests/Unit/RbacOnboardingServiceTest.php +++ b/apps/platform/tests/Unit/RbacOnboardingServiceTest.php @@ -27,7 +27,7 @@ function fakeTenant(): Tenant 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', 'entra_tenant_id' => (string) $tenant->tenant_id, ]); diff --git a/apps/platform/tests/Unit/ScopeTagResolverTest.php b/apps/platform/tests/Unit/ScopeTagResolverTest.php index 68cf9c05..55a70298 100644 --- a/apps/platform/tests/Unit/ScopeTagResolverTest.php +++ b/apps/platform/tests/Unit/ScopeTagResolverTest.php @@ -23,7 +23,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), @@ -71,7 +71,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), @@ -117,7 +117,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), @@ -146,7 +146,7 @@ 'tenant_id' => (int) $tenant->getKey(), 'provider' => 'microsoft', 'is_default' => true, - 'status' => 'connected', + 'consent_status' => 'granted', ]); ProviderCredential::factory()->create([ 'provider_connection_id' => (int) $connection->getKey(), diff --git a/docs/product/principles.md b/docs/product/principles.md index 5aa4fd0d..e7127f33 100644 --- a/docs/product/principles.md +++ b/docs/product/principles.md @@ -3,7 +3,7 @@ # Product Principles > Permanent product principles that govern every spec, every UI decision, and every architectural choice. > New specs must align with these. If a principle needs to change, update this file first. -**Last reviewed**: 2026-03-28 +**Last reviewed**: 2026-04-09 --- @@ -72,6 +72,11 @@ ### Persist only real truth New tables or stored artifacts exist only for independent truth, lifecycle, audit, retention, compliance, routing, or durable operator workflow needs. Convenience projections, UI helpers, and speculative artifacts stay derived. +### Compliance domains are modeled, not hardcoded +Framework-aligned requirements live as versioned control catalogs, TenantPilot technical interpretations, control-to-evidence mappings, evaluation rules, manual attestations, and customer/MSP profiles. +Readiness views, auditor packs, and framework-oriented reporting are outputs of that shared model, not bespoke framework-specific code paths. +When source framework versions, TenantPilot interpretation logic, and customer profile overrides can change independently, they must be versioned independently. + ### New state requires new behavior Statuses, reason codes, and lifecycle labels are domain truth only when they change operator action, routing, permissioning, lifecycle, retention, audit, or retry behavior. Otherwise they remain derived presentation. diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 440b4308..7483d0ec 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -3,7 +3,7 @@ # Product Roadmap > Strategic thematic blocks and release trajectory. > This is the "big picture" — not individual specs. -**Last updated**: 2026-03-23 +**Last updated**: 2026-04-09 --- @@ -110,6 +110,12 @@ ### Compliance Readiness & Executive Review Packs **Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation. **Depends on**: StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity. **Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation. +**Modeling principle**: Compliance and governance requirements are modeled as versioned control catalogs, TenantPilot technical interpretations, evidence mappings, evaluation rules, manual attestations, and customer/MSP profiles, not as hardcoded framework-specific rules. Readiness views, evidence packs, and auditor outputs are generated from that shared domain model. + +- Separate framework source versions, TenantPilot interpretation versions, and customer/MSP profile versions +- Map controls to evidence sources, evaluation rules, and manual attestations when automation is partial +- Keep BSI / NIS2 / CIS views as reporting layers on top of the shared control model +- Avoid framework-specific one-off reports that bypass the common evidence, findings, exception, and export pipeline ### Entra Role Governance Expand TenantPilot's governance coverage into Microsoft Entra role definitions and assignments as a first-class identity administration surface. diff --git a/docs/product/spec-candidates.md b/docs/product/spec-candidates.md index 56ece31a..41d5fb34 100644 --- a/docs/product/spec-candidates.md +++ b/docs/product/spec-candidates.md @@ -5,7 +5,7 @@ # Spec Candidates > > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` -**Last reviewed**: 2026-04-07 (added UI Discipline Trilogy: Record Page Header Discipline, Monitoring Surface Action Hierarchy, Governance Friction & Vocabulary Hardening) +**Last reviewed**: 2026-04-10 (added Compliance Control Catalog & Interpretation Foundation) --- @@ -579,6 +579,42 @@ ### Exception / Risk-Acceptance Workflow for Findings - **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134) - **Priority**: high +### Compliance Control Catalog & Interpretation Foundation +- **Type**: foundation +- **Source**: roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning +- **Vehicle**: new standalone candidate +- **Problem**: TenantPilot now has an explicit roadmap direction toward BSI-/NIS2-/CIS-oriented readiness views and executive review packs, but it still lacks the bounded domain foundation that those outputs should consume. There is no explicit, versioned model for external framework sources, TenantPilot's technical interpretation of those controls, customer/MSP profile variants, or the mapping from controls to evidence, evaluation rules, and manual attestations. Without that foundation, framework support will predictably drift into hardcoded report logic, one-off service rules, and special-case exports. The same underlying governance evidence will be translated differently per feature, and changes to framework source versions, TenantPilot interpretation logic, or customer profile overrides will become impossible to track independently. +- **Why it matters**: This is the difference between "framework-themed reports" and a sustainable compliance-readiness product. Enterprise and MSP buyers do not need TenantPilot to become a certification engine, but they do need repeatable, reviewable, version-aware mappings from governance evidence to framework-oriented control statements. A shared control-model foundation avoids three long-term failure modes: duplicated rule logic across multiple readiness/report features, inability to explain which product interpretation produced a given readiness result, and brittle customer-specific customizations that fork framework behavior instead of profiling it. If the product wants BSI/NIS2/CIS views later, it should first know which control source version, which TenantPilot interpretation version, and which customer profile produced each answer. +- **Proposed direction**: + - Introduce a bounded compliance domain model with explicit concepts for framework registry, framework versions, control catalog entries, TenantPilot interpretation records, customer/MSP profiles, profile overrides, control-to-evidence mappings, evaluation rules, and manual attestations + - Separate three independent version layers: framework source version, TenantPilot interpretation version, and customer/MSP profile version + - Allow each control to declare automation posture such as fully automatable, partially automatable, manual-attestation-required, or outside-product-scope + - Map one control to multiple evidence sources and evaluation rules, and allow one evidence source to support multiple controls + - Treat risk exceptions, findings, stored reports, and readiness views as downstream consumers of the control model rather than the place where framework logic lives + - Prefer pack/import-based control lifecycle management with preview, diff, activate, archive, and migration semantics over manual per-control CRUD as the primary maintenance path + - Start with lightweight, product-owned control metadata and interpretation summaries rather than assuming full storage of normative framework text inside the product +- **Scope boundaries**: + - **In scope**: framework registry/version model, control catalog foundation, interpretation/profile/override model, evidence and evaluation mapping model, manual attestation linkage, framework-pack import and diff lifecycle, bounded admin/registry surfaces where required to manage activation state and profile variants + - **Out of scope**: formal certification claims, legal/compliance advice, full framework-text publishing, comprehensive support for every control in every standard, broad stakeholder-facing reporting UI, one-off PDF generation, posture scoring models, or replacing the evidence domain with a second artifact store +- **Explicit non-goals**: + - Not a certification engine or legal interpretation layer + - Not a hardcoded per-framework report generator + - Not a requirement to ingest every framework in full before the first useful control family ships + - Not a promise that every control becomes fully automatable; manual attestation remains a first-class path +- **Acceptance points**: + - The system can represent and distinguish a framework source version, a TenantPilot interpretation version, and a customer/MSP profile version for the same control family + - A single control can map to multiple evidence requirements and evaluation rules, with optional manual attestation where automation is incomplete + - The model can express whether a control is fully automatable, partially automatable, manual-only, or outside current product scope + - A framework-pack update can preview new, changed, and retired controls before activation + - Framework-oriented readiness/reporting work can consume the shared model without introducing hardcoded BSI/NIS2/CIS rule paths in presentation features +- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, storage, completeness, and immutable artifacts. This candidate owns the normative control model, product interpretation layer, and mapping from controls to those evidence artifacts. +- **Boundary with Exception / Risk-Acceptance Workflow**: Risk Acceptance owns the lifecycle for documented deviations once a control gap or finding exists. This candidate owns how controls are modeled, interpreted, and linked to evidence before any exception is approved. +- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns stakeholder-facing views, review-pack composition, and report delivery. This candidate owns the shared framework/control layer those views should consume so readiness output does not hardcode framework semantics locally. +- **Dependencies**: Evidence Domain Foundation candidate (soft dependency for the final evidence-mapping contract), findings and exception workflow direction, StoredReports / review-pack export maturity for downstream consumers +- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 155 (tenant-review-layer), Exception / Risk-Acceptance Workflow for Findings, Compliance Readiness & Executive Review Packs, Security Posture Signals Foundation, Entra Role Governance, SharePoint Tenant-Level Sharing Governance +- **Strategic sequencing**: Best tackled before any substantial BSI/NIS2/CIS-oriented readiness views or auditor-pack expansion, and after or in parallel with Evidence Domain Foundation hardens the evidence side of the contract. This is not required to finish current R1/R2 governance hardening, but it should land before framework-facing readiness output becomes a real product lane. +- **Priority**: medium + ### Compliance Readiness & Executive Review Packs - **Type**: feature - **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance diff --git a/specs/188-provider-connection-state-cleanup/checklists/requirements.md b/specs/188-provider-connection-state-cleanup/checklists/requirements.md new file mode 100644 index 00000000..b2438243 --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Canonical Provider Connection State Cleanup + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-09 +**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 + +- Validated after first draft update on 2026-04-09. +- No clarification markers were required because the feature description explicitly defined scope, cutover policy, canonical truth separation, and non-goals. +- Route names and domain field names remain only where the repo template and product vocabulary require them for unambiguous scope and surface definition. \ No newline at end of file diff --git a/specs/188-provider-connection-state-cleanup/contracts/provider-connection-state-cleanup.openapi.yaml b/specs/188-provider-connection-state-cleanup/contracts/provider-connection-state-cleanup.openapi.yaml new file mode 100644 index 00000000..82b87743 --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/contracts/provider-connection-state-cleanup.openapi.yaml @@ -0,0 +1,431 @@ +openapi: 3.1.0 +info: + title: Canonical Provider Connection State Cleanup Internal Contract + version: 0.1.0 + summary: Internal planning contract for the hard cut from legacy provider status and health to canonical lifecycle, consent, and verification + description: | + This contract is an internal planning artifact for Spec 188. The affected + routes continue to render HTML. The schemas below describe the canonical + provider-state contract that must be derivable before rendering and before + runtime gates execute. Legacy `status` and `health_status` do not appear in + any schema because the feature requires a hard cut with no compatibility + payload. +servers: + - url: /internal +x-cutover-order: + - runtime_readers + - runtime_writers + - shared_presenters_and_badges + - factories_helpers_and_tests + - schema_removal +x-runtime-readers: + - surface: resolver_and_runtime_gates + guardScope: + - app/Services/Providers/ProviderConnectionResolver.php + - app/Jobs/ScanEntraAdminRolesJob.php + - app/Filament/Resources/ProviderConnectionResource.php + expectedContract: + - lifecycle_reads_is_enabled_only + - consent_inference_reads_consent_status_only + - verification_inference_reads_verification_status_only + - surface: tenant_and_system_summaries + guardScope: + - app/Filament/Resources/TenantResource.php + - resources/views/filament/infolists/entries/provider-connection-state.blade.php + - app/Filament/System/Pages/Directory/Tenants.php + - app/Filament/System/Pages/Directory/ViewTenant.php + - resources/views/filament/system/pages/directory/view-tenant.blade.php + expectedContract: + - helper_payload_has_no_legacy_status_keys + - system_rollups_derive_from_verification_and_permission_truth + - admin_and_system_planes_tell_the_same_provider_state_story +x-runtime-writers: + - surface: onboarding_and_consent_bootstrap + writerScope: + - app/Http/Controllers/TenantOnboardingController.php + - app/Http/Controllers/AdminConsentCallbackController.php + - app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php + expectedContract: + - initialize_is_enabled + - initialize_or_update_consent_status + - initialize_verification_status_without_legacy_projection + - surface: verification_and_health_check + writerScope: + - app/Services/Verification/StartVerification.php + - app/Jobs/ProviderConnectionHealthCheckJob.php + - app/Services/Providers/Contracts/HealthResult.php + - app/Services/Providers/MicrosoftProviderHealthCheck.php + - app/Services/Providers/ProviderConnectionStateProjector.php + expectedContract: + - verification_contract_uses_canonical_status + - verification_writes_update_only_canonical_fields_and_diagnostics + - surface: provider_mutations + writerScope: + - app/Services/Providers/ProviderConnectionMutationService.php + - app/Filament/Resources/ProviderConnectionResource.php + expectedContract: + - enable_disable_mutations_write_lifecycle_not_legacy_status + - credential_mutations_reset_or_block_verification_without_legacy_projection +x-legacy-removal-scope: + removed_columns: + - provider_connections.status + - provider_connections.health_status + removed_badge_domains: + - provider_connection.status + - provider_connection.health + removed_helper_keys: + - status + - health_status +paths: + /admin/provider-connections: + get: + summary: Render the canonical provider-connections list with lifecycle, consent, and verification as the only state axes + operationId: viewCanonicalProviderConnections + responses: + '200': + description: Provider-connections list rendered from canonical provider-state truth only + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-connections-canonical+json: + schema: + $ref: '#/components/schemas/ProviderConnectionListTruthBundle' + '302': + description: Existing admin tenant-scoping redirects still apply + '404': + description: Workspace or tenant scope is outside entitlement + /admin/provider-connections/{record}: + get: + summary: Render canonical provider connection detail without legacy provider status or health + operationId: viewCanonicalProviderConnection + parameters: + - name: record + in: path + required: true + schema: + type: + - integer + - string + responses: + '200': + description: Provider connection detail rendered from lifecycle, consent, verification, and diagnostics only + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-connection-canonical+json: + schema: + $ref: '#/components/schemas/ProviderConnectionDetailTruthModel' + '403': + description: Actor is in scope but lacks the capability required for protected actions + '404': + description: Provider connection is outside entitlement scope + /admin/provider-connections/{record}/edit: + get: + summary: Render canonical provider connection edit context with lifecycle, consent, and verification + operationId: editCanonicalProviderConnection + parameters: + - name: record + in: path + required: true + schema: + type: + - integer + - string + responses: + '200': + description: Provider connection edit rendered without legacy status or health fields + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.provider-connection-edit-canonical+json: + schema: + $ref: '#/components/schemas/ProviderConnectionDetailTruthModel' + '403': + description: Actor is in scope but lacks manage capability + '404': + description: Provider connection is outside entitlement scope + /admin/tenants/{tenant}: + get: + summary: Render tenant detail provider summary from canonical provider-state truth only + operationId: viewCanonicalTenantProviderSummary + parameters: + - name: tenant + in: path + required: true + schema: + type: string + responses: + '200': + description: Tenant detail rendered with a canonical provider summary and no legacy provider-state payload + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.tenant-provider-summary-canonical+json: + schema: + $ref: '#/components/schemas/TenantProviderSummary' + '404': + description: Tenant is outside entitlement scope + /system/directory/tenants: + get: + summary: Render system tenant directory with health rollups derived from canonical verification and permission truth + operationId: viewSystemTenantDirectoryCanonicalHealth + responses: + '200': + description: System directory list rendered without legacy provider health aggregation + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.system-tenant-directory-canonical+json: + schema: + $ref: '#/components/schemas/SystemTenantDirectoryTruthBundle' + '404': + description: Platform actor lacks directory access or tenant scope is unavailable + /system/directory/tenants/{tenant}: + get: + summary: Render system tenant detail with canonical provider rows + operationId: viewSystemTenantCanonicalProviderRows + parameters: + - name: tenant + in: path + required: true + schema: + type: + - integer + - string + responses: + '200': + description: System tenant detail rendered with canonical provider-state rows only + content: + text/html: + schema: + type: string + application/vnd.tenantpilot.system-tenant-canonical-provider-rows+json: + schema: + $ref: '#/components/schemas/SystemTenantDetailTruthModel' + '404': + description: Platform actor lacks directory access or tenant is unavailable +components: + schemas: + LifecycleState: + type: string + enum: + - enabled + - disabled + ProviderConsentState: + type: string + enum: + - unknown + - required + - granted + - failed + - revoked + ProviderVerificationState: + type: string + enum: + - unknown + - pending + - healthy + - degraded + - blocked + - error + ProviderDiagnostics: + type: object + required: + - migrationReviewRequired + properties: + lastCheckedAt: + type: + - string + - 'null' + lastErrorReasonCode: + type: + - string + - 'null' + lastErrorMessage: + type: + - string + - 'null' + consentErrorCode: + type: + - string + - 'null' + consentErrorMessage: + type: + - string + - 'null' + migrationReviewRequired: + type: boolean + CanonicalProviderConnectionState: + type: object + required: + - lifecycle + - isEnabled + - consentStatus + - verificationStatus + - diagnostics + properties: + lifecycle: + $ref: '#/components/schemas/LifecycleState' + isEnabled: + type: boolean + consentStatus: + oneOf: + - $ref: '#/components/schemas/ProviderConsentState' + - type: 'null' + verificationStatus: + oneOf: + - $ref: '#/components/schemas/ProviderVerificationState' + - type: 'null' + diagnostics: + $ref: '#/components/schemas/ProviderDiagnostics' + ProviderConnectionRow: + type: object + required: + - id + - displayName + - provider + - connectionType + - isDefault + - state + properties: + id: + type: integer + tenantLabel: + type: + - string + - 'null' + displayName: + type: string + provider: + type: string + connectionType: + type: string + isDefault: + type: boolean + state: + $ref: '#/components/schemas/CanonicalProviderConnectionState' + ProviderConnectionListTruthBundle: + type: object + required: + - rows + properties: + rows: + type: array + items: + $ref: '#/components/schemas/ProviderConnectionRow' + ProviderConnectionDetailTruthModel: + type: object + required: + - id + - displayName + - provider + - connectionType + - isDefault + - state + properties: + id: + type: integer + displayName: + type: string + provider: + type: string + connectionType: + type: string + isDefault: + type: boolean + effectiveAppId: + type: + - string + - 'null' + effectiveAppSource: + type: + - string + - 'null' + state: + $ref: '#/components/schemas/CanonicalProviderConnectionState' + TenantProviderSummary: + type: object + required: + - state + - ctaUrl + - needsDefaultConnection + properties: + state: + type: string + enum: + - missing + - configured + - default_configured + ctaUrl: + type: string + needsDefaultConnection: + type: boolean + displayName: + type: + - string + - 'null' + provider: + type: + - string + - 'null' + canonicalState: + oneOf: + - $ref: '#/components/schemas/CanonicalProviderConnectionState' + - type: 'null' + SystemTenantDirectoryRow: + type: object + required: + - tenantId + - tenantLabel + - workspaceLabel + - providerConnectionsCount + - criticalProviderCount + - warningProviderCount + - missingPermissionCount + - systemHealth + properties: + tenantId: + type: integer + tenantLabel: + type: string + workspaceLabel: + type: string + providerConnectionsCount: + type: integer + criticalProviderCount: + type: integer + warningProviderCount: + type: integer + missingPermissionCount: + type: integer + systemHealth: + type: string + SystemTenantDirectoryTruthBundle: + type: object + required: + - rows + properties: + rows: + type: array + items: + $ref: '#/components/schemas/SystemTenantDirectoryRow' + SystemTenantDetailTruthModel: + type: object + required: + - tenantId + - tenantLabel + - providerConnections + properties: + tenantId: + type: integer + tenantLabel: + type: string + providerConnections: + type: array + items: + $ref: '#/components/schemas/ProviderConnectionRow' \ No newline at end of file diff --git a/specs/188-provider-connection-state-cleanup/data-model.md b/specs/188-provider-connection-state-cleanup/data-model.md new file mode 100644 index 00000000..7605a48c --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/data-model.md @@ -0,0 +1,272 @@ +# Phase 1 Data Model: Canonical Provider Connection State Cleanup + +## Overview + +This feature removes the persisted legacy provider-state columns `status` and `health_status` and replaces their last remaining behavioral responsibility with one explicit lifecycle truth: `is_enabled`. Consent and verification remain on their existing canonical enums. No new table, no new persisted summary artifact, and no new cross-domain provider-state framework are introduced. + +## Persistent Source Truths + +### ProviderConnection + +**Purpose**: Canonical tenant-owned provider integration record for provider lifecycle, consent, verification, and supporting diagnostics. + +**Key fields**: +- `id` +- `tenant_id` +- `workspace_id` +- `provider` +- `entra_tenant_id` +- `display_name` +- `is_default` +- `connection_type` +- `is_enabled` +- `consent_status` +- `consent_granted_at` +- `consent_last_checked_at` +- `consent_error_code` +- `consent_error_message` +- `verification_status` +- `migration_review_required` +- `migration_reviewed_at` +- `scopes_granted` +- `last_health_check_at` +- `last_error_reason_code` +- `last_error_message` +- `metadata` + +**Removed fields**: +- `status` +- `health_status` + +**Relationships**: +- `ProviderConnection` belongs to `Tenant` +- `ProviderConnection` belongs to `Workspace` +- `ProviderConnection` has one `ProviderCredential` + +**Validation rules**: +- `is_enabled` is the only persisted lifecycle truth and defaults to `true` for newly created connections. +- `consent_status` remains the only consent truth. +- `verification_status` remains the only verification truth. +- `status` and `health_status` are not recreated through accessors, casts, appended attributes, helper arrays, or audit context shims. +- Existing unique constraints on tenant, provider, and Entra tenant identity remain unchanged. +- Existing indexes for `consent_status` and `verification_status` remain. Legacy indexes tied to removed columns are dropped. No new lifecycle index is introduced unless implementation profiling proves it necessary. + +### ProviderCredential + +**Purpose**: Encrypted credential material associated with a provider connection. + +**Key fields**: +- `provider_connection_id` +- encrypted credential payload fields + +**Relationships**: +- `ProviderCredential` belongs to `ProviderConnection` + +**Validation rules**: +- This feature does not change credential storage or encryption. +- Credential presence remains a diagnostic or blocker input for lifecycle-safe verification semantics, not a fourth provider-state dimension. + +### HealthResult (internal contract) + +**Purpose**: Canonical result object emitted by provider health checks before persistence. + +**Key fields**: +- `verification_status` +- `reason_code` +- `message` +- `meta` + +**Validation rules**: +- The contract must not carry legacy `status` or `health_status` fields. +- The contract may carry only the minimum data needed to derive canonical consent and verification outcomes plus diagnostics. + +## Canonical State Families + +### Lifecycle (derived from `is_enabled`) + +**Persisted form**: +- `is_enabled = true` +- `is_enabled = false` + +**Operator-facing form**: +- `Enabled` +- `Disabled` + +**Rule**: +- Lifecycle answers only whether the connection is administratively allowed to operate. +- Lifecycle does not imply consent and does not imply the latest verification outcome. + +### ProviderConsentStatus + +**Values**: +- `unknown` +- `required` +- `granted` +- `failed` +- `revoked` + +**Rule**: +- Consent answers only whether required provider consent currently exists and whether consent-specific failure or revocation was detected. +- Consent does not imply lifecycle and does not imply technical verification success. + +### ProviderVerificationStatus + +**Values**: +- `unknown` +- `pending` +- `healthy` +- `degraded` +- `blocked` +- `error` + +**Rule**: +- Verification answers only what the latest verification attempt or local canonical blocker currently proves. +- Verification does not imply lifecycle and does not rewrite consent. + +## State Transitions + +### Lifecycle transitions + +| From | To | Trigger | Notes | +|------|----|---------|-------| +| `true` | `false` | Explicit disable action | Consent and verification remain intact as separate truths. | +| `false` | `true` | Explicit enable action | Lifecycle resumes without fabricating consent or a healthy verification state. | + +### Consent transitions + +| From | To | Trigger | Notes | +|------|----|---------|-------| +| `unknown` or `required` | `granted` | Successful admin consent callback or successful verification that proves consent still exists | Does not change lifecycle. | +| `granted` | `revoked` | Verification detects consent was revoked | Verification may become `blocked`, but lifecycle remains separate. | +| any | `failed` | Consent-specific failure during callback or verification | Does not disable the connection. | + +### Verification transitions + +| From | To | Trigger | Notes | +|------|----|---------|-------| +| `unknown` | `pending` | Verification start | Existing start surfaces remain authoritative. | +| `pending` | `healthy`, `degraded`, `blocked`, or `error` | Verification completion | Derived from the health-check result and current consent truth. | +| any | `unknown` | New connection creation, successful enable with present credentials, or credential mutation that invalidates prior verification but does not prove a blocker | Keeps stale success from surviving a material configuration change. | +| any | `blocked` | Local or remote evidence proves a blocker, such as missing credentials, invalid connection type, consent missing, or review-required state | Blocked remains a verification consequence, not a lifecycle substitute. | + +## Mutation Rules + +### Connection creation and onboarding bootstrap + +**Expected persisted shape**: +- `is_enabled = true` +- `consent_status = required` for pre-consent onboarding starts +- `verification_status = unknown` +- diagnostics cleared or initialized from the current event + +### Consent callback + +**Expected persisted shape**: +- lifecycle unchanged or initialized to `is_enabled = true` +- `consent_status` updated from callback outcome +- `verification_status = unknown` +- consent and error timestamps refreshed + +### Verification start + +**Expected persisted shape**: +- lifecycle unchanged +- consent unchanged +- `verification_status = pending` +- legacy status or health projections are not written + +### Verification completion / health check + +**Expected persisted shape**: +- lifecycle unchanged +- `consent_status` updated only when the result proves a consent consequence +- `verification_status` updated from the canonical health result +- error and recency diagnostics refreshed + +### Enable and disable actions + +**Expected persisted shape**: +- Disable writes only `is_enabled = false` plus any audit metadata that records the lifecycle change. +- Enable writes `is_enabled = true` and resets stale verification truth without fabricating a healthy state. +- When enabling reveals a local blocker such as missing credentials, `verification_status` becomes `blocked` and the blocker reason is recorded in diagnostics. + +### Credential-source and mutation-service changes + +**Expected persisted shape**: +- lifecycle unchanged unless the operator explicitly disables the connection +- consent unchanged unless the mutation directly changes consent evidence +- verification reset to `unknown` or `blocked` depending on whether the mutation invalidates prior verification or proves a blocker + +## Derived Surface Contracts + +### Canonical provider state bundle + +**Purpose**: Shared derived state used by provider detail, edit, tenant summaries, and system read-only views. + +| Field | Type | Required | Description | +|------|------|----------|-------------| +| `lifecycle` | string | yes | `enabled` or `disabled`, derived from `is_enabled` | +| `isEnabled` | boolean | yes | Raw persisted lifecycle truth | +| `consentStatus` | string nullable | yes | Canonical consent status | +| `verificationStatus` | string nullable | yes | Canonical verification status | +| `connectionType` | string | yes | Platform or dedicated | +| `isDefault` | boolean | yes | Default designation | +| `lastCheckedAt` | string nullable | no | Stored verification recency | +| `lastErrorReasonCode` | string nullable | no | Latest diagnostic reason | +| `lastErrorMessage` | string nullable | no | Latest diagnostic message | +| `migrationReviewRequired` | boolean | yes | Existing migration-review diagnostic | + +**Validation rules**: +- No helper array or view model may expose `status` or `health_status` keys. +- Diagnostics remain subordinate to lifecycle, consent, and verification. + +### Tenant provider summary + +**Purpose**: Compact provider summary rendered from `TenantResource::providerConnectionState()` and the shared provider-state Blade entry. + +| Field | Type | Required | Description | +|------|------|----------|-------------| +| `state` | string | yes | `missing`, `configured`, or `default_configured` | +| `cta_url` | string | yes | Canonical provider-connections destination | +| `needs_default_connection` | boolean | yes | Signals action needed without inventing provider health | +| `display_name` | string nullable | no | Chosen connection display name | +| `provider` | string nullable | no | Provider label | +| `is_enabled` | boolean nullable | no | Raw lifecycle truth when a connection exists | +| `consent_status` | string nullable | no | Canonical consent status | +| `verification_status` | string nullable | no | Canonical verification status | +| `last_health_check_at` | string nullable | no | Stored verification recency | +| `last_error_reason_code` | string nullable | no | Latest diagnostic reason | + +**Validation rules**: +- Missing or non-default states must never read as healthy or ready. +- The summary must not return removed legacy keys for compatibility. + +### System tenant health rollup + +**Purpose**: Read-only system-directory aggregate used to derive `SystemHealth` badges. + +| Field | Type | Required | Description | +|------|------|----------|-------------| +| `tenantStatus` | string | yes | Existing tenant lifecycle/status input | +| `criticalProviderCount` | integer | yes | Count of each tenant's default Microsoft provider connection when its `verification_status` is `blocked` or `error` | +| `warningProviderCount` | integer | yes | Count of each tenant's default Microsoft provider connection when its `verification_status = degraded` | +| `missingPermissionCount` | integer | yes | Existing tenant-permission signal | + +**Validation rules**: +- System health rollups must not query removed `health_status` values. +- Tenant-directory list rollups count only the default Microsoft provider connection for each tenant. Non-default connections may still appear on the system tenant detail page, but they do not affect list rollup counts. +- The same provider connection must produce semantically aligned admin and system readings. + +## Removal Rules + +1. `status` and `health_status` are removed from the database schema, Eloquent model interactions, helper arrays, Blade views, badge domains, query filters, and audit metadata. +2. `ProviderConnection::classificationProjection()` and any equivalent projection helpers no longer emit removed fields. +3. Runtime gates read lifecycle from `is_enabled`, not from any verification or consent surrogate. +4. Diagnostics remain stored and displayed only as supporting facts, not as another state language. + +## No New Persistence Beyond The Narrow Lifecycle Field + +- No new table is introduced. +- No new provider-readiness summary is persisted. +- No new enum class is introduced for lifecycle; the persisted truth is a boolean and the operator-facing labels are derived. +- The cleanup removes more persisted truth than it adds: two legacy columns out, one lifecycle column in. \ No newline at end of file diff --git a/specs/188-provider-connection-state-cleanup/plan.md b/specs/188-provider-connection-state-cleanup/plan.md new file mode 100644 index 00000000..c8ffd65a --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/plan.md @@ -0,0 +1,308 @@ +# Implementation Plan: Canonical Provider Connection State Cleanup + +**Branch**: `188-provider-connection-state-cleanup` | **Date**: 2026-04-09 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/188-provider-connection-state-cleanup/spec.md` +**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/188-provider-connection-state-cleanup/spec.md` + +**Note**: This plan is a hard-cut cleanup. It removes legacy provider connection truth instead of layering a compatibility bridge on top of it. + +## Summary + +Replace legacy provider connection `status` and `health_status` with one explicit lifecycle truth, `is_enabled`, beside the existing canonical `consent_status` and `verification_status`. The implementation adds that narrow lifecycle column, moves every runtime reader and writer onto the three canonical dimensions, trims health-check and projector contracts so they stop emitting legacy state, rewrites admin and system surfaces to consume lifecycle, consent, and verification plus diagnostics only, retires legacy provider badge domains, updates factories and Pest helpers, and then drops the removed columns and their indexes in the final migration. + +## Technical Context + +**Language/Version**: PHP 8.4.15 +**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4, Pest v4, existing `ProviderConnection` model, `ProviderConnectionResolver`, `ProviderConnectionStateProjector`, `ProviderConnectionMutationService`, `ProviderConnectionHealthCheckJob`, `StartVerification`, `ProviderConnectionResource`, `TenantResource`, system directory pages, `BadgeCatalog`, `BadgeRenderer`, and shared provider-state Blade entries +**Storage**: PostgreSQL with one narrow schema addition (`is_enabled`) followed by final removal of legacy `status` and `health_status` columns and their indexes +**Testing**: Pest 4 feature and unit coverage via Laravel Sail, reusing provider connection, onboarding, verification, audit, badge, and system-directory tests plus one new residual guard for removed legacy state usage +**Target Platform**: Laravel monolith web application running in Sail locally and containerized Linux environments in staging and production +**Project Type**: web application +**Performance Goals**: Preserve DB-only rendering guarantees on touched pages, avoid extra per-row remote calls or presenter layers, keep resolver and system-directory queries index-friendly, and keep canonical provider summaries bounded to existing record lookups +**Constraints**: No dual-read or dual-write compatibility period, no new provider-state framework, no new persisted summary artifact, no new badge taxonomy for lifecycle, no authorization widening, no cross-plane truth drift between `/admin` and `/system`, and no destructive-action safety regression +**Scale/Scope**: One tenant-owned model, one narrow lifecycle column, two removed legacy columns, at least six runtime read paths, at least six runtime write paths, six operator-facing surfaces, two shared Blade views, the centralized badge registry, shared factory and Pest helper scaffolding, and a focused regression pack spanning provider, onboarding, verification, audit, and directory flows + +## Constitution Check + +*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Pre-Research | Post-Design | Notes | +|-----------|--------------|-------------|-------| +| Inventory-first / snapshots-second | PASS | PASS | The feature changes only live provider-connection truth and its operator-facing interpretation. No snapshot or backup contract changes are introduced. | +| Read/write separation | PASS | PASS | Existing provider mutations, onboarding callbacks, and verification flows remain the only write paths. No new mutation surface is introduced. | +| Graph contract path | PASS | PASS | No Graph endpoint family or contract registry expansion is required. Existing provider health checks remain behind the current provider gateway abstractions. | +| Deterministic capabilities | PASS | PASS | Existing provider view and manage capabilities remain authoritative. No role or capability remapping is introduced. | +| RBAC-UX authorization semantics | PASS | PASS | `/admin` and `/system` stay plane-separated, non-members remain `404`, member-but-missing-capability remains `403`, and existing provider mutations stay server-authorized. | +| Workspace and tenant isolation | PASS | PASS | No new route or query broadens tenant visibility. Tenant resource helpers and system-directory views stay scoped to entitled records only. | +| Run observability / Ops-UX | PASS | PASS | Existing verification and provider health-check runs keep their current `OperationRun` semantics. This cleanup changes state persistence and rendering only. | +| Data minimization | PASS | PASS | The plan removes redundant persisted truth and does not add new logs, secrets, or summary artifacts. | +| Proportionality / no premature abstraction | PASS | PASS | The design adds one narrow lifecycle column and reuses existing badge, resource, and verification infrastructure instead of introducing a provider-state framework or DTO layer. | +| Persisted truth / behavioral state | PASS | PASS | One new persisted lifecycle truth is justified because `disabled` currently has real behavioral consequences. Two redundant legacy columns are removed. | +| UI semantics / few layers | PASS | PASS | Surfaces consume lifecycle, consent, and verification directly from canonical truth with thin helpers. No new readiness taxonomy or presenter stack is introduced. | +| Badge semantics (BADGE-001) | PASS | PASS | `BadgeDomain::BooleanEnabled`, `ProviderConsentStatus`, and `ProviderVerificationStatus` are reused. Legacy provider badge domains are retired instead of retained as compatibility layers. | +| Filament-native UI / Action Surface Contract | PASS | PASS | Existing Filament resources, infolists, tables, actions, and read-only directory pages are reused. Destructive-like actions remain confirmed and capability-gated. | +| Filament UX-001 | PASS | PASS | No new screen type is introduced. Existing list, detail, edit, and read-only directory surfaces remain within current Filament layout patterns. | +| Filament v5 / Livewire v4 compliance | PASS | PASS | The design stays within the current Filament v5 and Livewire v4 stack. | +| Provider registration location | PASS | PASS | No panel or provider registration change is required. Laravel 11+ provider registration remains in `bootstrap/providers.php`. | +| Global search hard rule | PASS | PASS | `TenantResource` remains globally searchable and already has view/edit pages. `ProviderConnectionResource` remains non-globally-searchable. | +| Destructive action safety | PASS | PASS | Existing enable, disable, and credential-affecting actions keep confirmation, authorization, and audit requirements. | +| Asset strategy | PASS | PASS | No new Filament assets or deploy-time `filament:assets` changes are required. | +| Testing truth (TEST-TRUTH-001) | PASS | PASS | The plan updates real provider and onboarding tests and adds a residual guard rather than creating new testing indirection. | + +## Phase 0 Research + +Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/188-provider-connection-state-cleanup/research.md`. + +Key decisions: + +- Persist lifecycle as a single `is_enabled` boolean and present it as the operator-facing `Lifecycle` dimension. +- Keep `ProviderConsentStatus` and `ProviderVerificationStatus` as the only consent and verification truths; do not introduce a combined readiness state. +- Remove legacy projector outputs and trim `ProviderConnectionStateProjector` to canonical verification-outcome logic only. +- Replace legacy `HealthResult` transport fields with canonical verification output and diagnostics. +- Enforce the hard-cut sequence: readers first, writers second, presentation and badge cleanup third, tests and helpers fourth, schema removal last. +- Recompute system-directory health rollups from canonical verification and permission truth. +- Reuse existing centralized badge infrastructure and retire legacy provider badge domains. +- Change shared provider-state helpers and views to emit lifecycle, consent, and verification only. +- Update factories and Pest helpers before the final drop migration and add contradiction and residual-guard coverage. + +## Phase 1 Design + +Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/188-provider-connection-state-cleanup/`: + +- `research.md`: planning decisions and rejected alternatives +- `data-model.md`: canonical persistence model, state transitions, mutation rules, and derived surface contracts +- `contracts/provider-connection-state-cleanup.openapi.yaml`: internal route, runtime reader/writer, and surface contract +- `quickstart.md`: phased implementation and validation workflow + +Design highlights: + +- `ProviderConnection` gains one narrow lifecycle column, `is_enabled`, and loses `status` and `health_status` in the final schema state. +- Lifecycle is rendered through the existing `BooleanEnabled` badge semantics and labeled as `Lifecycle` on provider surfaces. +- Consent and verification stay on their current enums and become the only non-lifecycle state axes across `/admin` and `/system`. +- `HealthResult` and `ProviderConnectionStateProjector` stop serving the removed legacy vocabulary. +- Provider enable or disable, onboarding, consent callback, verification start, credential mutation, and health-check writers all persist canonical state and diagnostics only. +- `ProviderConnectionResource`, `TenantResource`, the shared provider-state Blade entry, and system directory pages lose legacy provider status and health fields, filters, and badge domains. +- Factories, Pest helpers, and targeted regression suites are updated before the final drop migration lands. + +## Phase 1 — Agent Context Update + +Run after artifact generation: + +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Project Structure + +### Documentation (this feature) + +```text +specs/188-provider-connection-state-cleanup/ +├── spec.md +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── provider-connection-state-cleanup.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ └── Workspaces/ +│ │ │ └── ManagedTenantOnboardingWizard.php +│ │ ├── Resources/ +│ │ │ ├── ProviderConnectionResource.php +│ │ │ └── TenantResource.php +│ │ └── System/ +│ │ └── Pages/ +│ │ └── Directory/ +│ │ ├── Tenants.php +│ │ └── ViewTenant.php +│ ├── Http/ +│ │ └── Controllers/ +│ │ ├── AdminConsentCallbackController.php +│ │ └── TenantOnboardingController.php +│ ├── Jobs/ +│ │ ├── ProviderConnectionHealthCheckJob.php +│ │ └── ScanEntraAdminRolesJob.php +│ ├── Models/ +│ │ └── ProviderConnection.php +│ ├── Services/ +│ │ ├── Providers/ +│ │ │ ├── Contracts/ +│ │ │ │ └── HealthResult.php +│ │ │ ├── MicrosoftProviderHealthCheck.php +│ │ │ ├── ProviderConnectionMutationService.php +│ │ │ ├── ProviderConnectionResolver.php +│ │ │ └── ProviderConnectionStateProjector.php +│ │ └── Verification/ +│ │ └── StartVerification.php +│ └── Support/ +│ └── Badges/ +│ ├── BadgeCatalog.php +│ ├── BadgeDomain.php +│ └── Domains/ +│ ├── BooleanEnabledBadge.php +│ ├── ProviderConnectionHealthBadge.php +│ └── ProviderConnectionStatusBadge.php +├── database/ +│ ├── factories/ +│ │ └── ProviderConnectionFactory.php +│ └── migrations/ +│ ├── 2026_01_24_000001_create_provider_connections_table.php +│ ├── 2026_02_04_090020_make_provider_connections_workspace_owned.php +│ ├── 2026_03_13_000001_add_provider_identity_fields_to_provider_connections.php +│ ├── 2026_04_09_000001_add_is_enabled_to_provider_connections.php +│ └── 2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php +├── resources/ +│ └── views/ +│ └── filament/ +│ ├── infolists/ +│ │ └── entries/ +│ │ └── provider-connection-state.blade.php +│ └── system/ +│ └── pages/ +│ └── directory/ +│ └── view-tenant.blade.php +└── tests/ + ├── Feature/ + │ ├── Audit/ + │ │ ├── ProviderConnectionConsentAuditTest.php + │ │ ├── ProviderConnectionConsentRevocationAuditTest.php + │ │ └── ProviderConnectionVerificationAuditTest.php + │ ├── EntraAdminRoles/ + │ │ └── ScanEntraAdminRolesJobTest.php + │ ├── Filament/ + │ │ └── ProviderConnectionsDbOnlyTest.php + │ ├── Guards/ + │ │ └── NoLegacyProviderConnectionStateFallbackTest.php + │ ├── Onboarding/ + │ │ ├── OnboardingProviderConnectionPlatformDefaultTest.php + │ │ └── OnboardingProviderConnectionTest.php + │ ├── ProviderConnections/ + │ │ ├── ProviderConnectionEnableDisableTest.php + │ │ ├── ProviderConnectionHealthCheckJobTest.php + │ │ ├── ProviderConnectionHealthCheckStartSurfaceTest.php + │ │ ├── ProviderConnectionListAuthorizationTest.php + │ │ ├── ProviderConnectionTruthCleanupSpec179Test.php + │ │ ├── ProviderConnectionViewsDbOnlyRenderingSpec081Test.php + │ │ └── ProviderConnectionAuthorizationTest.php + │ ├── Verification/ + │ │ └── ProviderConnectionHealthCheckWritesReportTest.php + │ ├── ManagedTenantOnboardingWizardTest.php + │ └── Tenants/ + │ └── TenantProviderConnectionsCtaTest.php + └── Unit/ + ├── Badges/ + │ ├── BooleanEnabledBadgesTest.php + │ └── ProviderConnectionBadgesTest.php + └── Providers/ + ├── ProviderConnectionBadgeMappingTest.php + └── ProviderConnectionClassifierTest.php +``` + +**Structure Decision**: Keep the feature entirely inside the existing Laravel/Filament monolith. Update the current model, services, resources, shared Blade views, badge registry, migrations, and focused test files instead of introducing a new provider-state subsystem. + +## Complexity Tracking + +> No Constitution Check violation is planned. The feature does trigger proportionality review because it changes persisted truth and the provider-state surface contract. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Proportionality Review + +- **Current operator problem**: The same provider connection can still read as disabled, connected, healthy, consented, or verified depending on which legacy field a runtime path or surface reads, which makes provider state unreliable. +- **Existing structure is insufficient because**: `consent_status` and `verification_status` already exist, but legacy `status` and `health_status` still persist, still drive runtime gates, and still appear in admin and system surfaces. +- **Narrowest correct implementation**: Add one explicit lifecycle truth, `is_enabled`, remove the two legacy columns, and repoint all affected readers, writers, badges, helpers, and tests to lifecycle, consent, and verification directly. +- **Ownership cost created**: One migration pair, focused updates across existing services and resources, badge cleanup, shared helper cleanup, and a targeted regression and residual-guard suite. +- **Alternative intentionally rejected**: Dual-read or dual-write compatibility shims, a new provider readiness framework, and keeping legacy badge domains as diagnostics. +- **Release truth**: Current-release truth cleanup. + +## Implementation Strategy + +### Phase A — Add Canonical Lifecycle Truth And Prepare The Model + +**Goal**: Introduce the narrow lifecycle field needed to preserve enabled or disabled behavior without touching removed columns yet. + +| Step | File | Change | +|------|------|--------| +| A.1 | `apps/platform/database/migrations/2026_04_09_000001_add_is_enabled_to_provider_connections.php` | Add `is_enabled` to `provider_connections`, default it to `true`, and backfill `false` where legacy `status = disabled`. | +| A.2 | `apps/platform/app/Models/ProviderConnection.php` | Add the new lifecycle field to the model contract and stop any model helper from treating `status` or `health_status` as canonical state. | +| A.3 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php` | Extend lifecycle tests so the new field becomes the canonical enable or disable truth immediately. | + +### Phase B — Move Runtime Readers Off Legacy Provider State + +**Goal**: Make all canonical readers safe before any writer stops populating legacy columns. This phase includes shared helper, operator-surface, and runtime-gate reads that establish the reader-cutover gate for the hard cut. + +| Step | File | Change | +|------|------|--------| +| B.1 | `apps/platform/app/Services/Providers/ProviderConnectionResolver.php` | Replace `status === disabled` and `status === needs_consent` fallbacks with `is_enabled`, `consent_status`, and `verification_status`. | +| B.2 | `apps/platform/app/Jobs/ScanEntraAdminRolesJob.php` | Stop filtering on `status = connected`; gate on canonical lifecycle and consent rules instead. | +| B.3 | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` | Move action visibility, list filters, and detail context from legacy `status` or `health_status` to lifecycle, consent, and verification. | +| B.4 | `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/System/Pages/Directory/Tenants.php`, and `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php` | Stop tenant and system summaries from reading legacy provider columns. | +| B.5 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php` | Prove canonical reader cutover before writers change. | + +### Phase C — Move Runtime Writers And Internal Contracts To Canonical State + +**Goal**: Stop all runtime writes and transport contracts from recreating removed legacy fields. + +| Step | File | Change | +|------|------|--------| +| C.1 | `apps/platform/app/Services/Providers/Contracts/HealthResult.php` and `apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php` | Replace legacy transport fields with canonical verification output and diagnostics. | +| C.2 | `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` | Remove legacy `project()` and `projectForConnection()` responsibilities and keep only canonical verification-outcome derivation if it remains shared. | +| C.3 | `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php` and `apps/platform/app/Services/Verification/StartVerification.php` | Persist lifecycle, consent, verification, and diagnostics only. | +| C.4 | `apps/platform/app/Http/Controllers/AdminConsentCallbackController.php`, `apps/platform/app/Http/Controllers/TenantOnboardingController.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` | Remove dual-write behavior and reset canonical verification truth appropriately when onboarding, consent, or credential mutations occur. | +| C.5 | `apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php`, `apps/platform/tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php`, and `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` | Prove create-path coverage, writer cutover, and contract cleanup. | + +### Phase D — Remove Legacy Presentation And Badge Semantics + +**Goal**: Finalize operator-facing labels, summaries, and badge semantics after the reader and writer cutovers are already in place. This phase is presentation cleanup, not a replacement for the earlier reader-cutover gate. + +| Step | File | Change | +|------|------|--------| +| D.1 | `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` | Replace legacy status and health sections, columns, and diagnostic filters with lifecycle, consent, and verification presentation. | +| D.2 | `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php` | Change the shared tenant provider summary contract so it exposes only lifecycle, consent, verification, and diagnostics. | +| D.3 | `apps/platform/app/Filament/System/Pages/Directory/Tenants.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, and `apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php` | Recompute system health rollups and provider rows from canonical verification and permission truth, counting only each tenant's default Microsoft connection in list rollups while keeping detail rows read-only and canonical. | +| D.4 | `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, and `apps/platform/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php` | Retire legacy provider badge domains and reuse `BooleanEnabled`, provider consent, and provider verification badge mappings. | +| D.5 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php`, `apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, `apps/platform/tests/Feature/Filament/TenantScopingTest.php`, `apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `apps/platform/tests/Unit/Badges/BooleanEnabledBadgesTest.php`, `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php`, `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`, and `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php` | Prove canonical presentation, search safety, DB-only rendering, and centralized badge semantics. | + +### Phase E — Update Shared Test Scaffolding And Add Residual Guards + +**Goal**: Prevent helpers and future regressions from recreating removed provider-state truth. + +| Step | File | Change | +|------|------|--------| +| E.1 | `apps/platform/database/factories/ProviderConnectionFactory.php` and `apps/platform/tests/Pest.php` | Stop creating provider fixtures with legacy `status` or `health_status` values and move default healthy fixtures onto lifecycle, consent, and verification only. | +| E.2 | `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php` and related provider truth tests | Add contradiction scenarios such as disabled plus granted consent and disabled plus `verification_status = healthy`. | +| E.3 | `apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php` | Fail if targeted provider-state runtime files still reference removed provider columns or legacy provider badge domains. | + +### Phase F — Remove Legacy Columns And Finish The Hard Cut + +**Goal**: Complete the schema cleanup only after runtime, UI, and test scaffolding no longer depend on the removed fields. + +| Step | File | Change | +|------|------|--------| +| F.1 | `apps/platform/database/migrations/2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php` | Drop `status`, `health_status`, and their indexes from `provider_connections`. | +| F.2 | `apps/platform/app/Models/ProviderConnection.php` and any remaining touched files | Remove any leftover references to the removed columns, including diagnostic accessors, audit metadata keys, and helper-array comments. | +| F.3 | Focused regression pack from `quickstart.md` | Re-run runtime, surface, badge, onboarding, audit, and residual-guard coverage after the drop migration. | + +## Post-Design Constitution Re-Check + +The design still passes the Constitution after Phase 1: + +- It removes redundant truth instead of adding layers. +- It introduces one narrow persisted lifecycle fact because current behavior requires it. +- It keeps existing authorization, confirmation, and audit rules intact. +- It keeps Filament v5 and Livewire v4 compliance. +- It requires no panel-provider registration change in `bootstrap/providers.php`. +- It keeps `ProviderConnectionResource` non-globally-searchable and leaves tenant global search unchanged. +- It requires no new asset registration or `filament:assets` deployment work. +- It keeps testing focused on real provider behavior rather than new indirection. \ No newline at end of file diff --git a/specs/188-provider-connection-state-cleanup/quickstart.md b/specs/188-provider-connection-state-cleanup/quickstart.md new file mode 100644 index 00000000..65ab7193 --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/quickstart.md @@ -0,0 +1,183 @@ +# Quickstart: Canonical Provider Connection State Cleanup + +## Goal + +Validate that provider connections use exactly three canonical business dimensions after the cutover: + +- `Lifecycle` from `is_enabled` +- `Consent` from `consent_status` +- `Verification` from `verification_status` + +The release is not complete until runtime readers, runtime writers, shared helpers, system-directory summaries, badges, factories, and tests stop depending on `status` and `health_status`. + +## Prerequisites + +1. Start Sail. +2. Work from the feature branch with the new lifecycle migration available. +3. Prepare seeded scenarios for at least: + - disabled connection with granted consent and `verification_status = healthy` + - enabled connection with required consent and unknown verification + - enabled connection with granted consent and blocked verification caused by missing credentials + - tenant with no default Microsoft provider connection +4. Ensure one workspace member with provider manage access and one out-of-scope actor are available for authorization checks. + +## Validation Order + +### 1. Run the reader-cutover regression pack + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php +``` + +Expected outcome: +- Resolver gates, action visibility, enable or disable behavior, and the Entra admin-roles scan all evaluate lifecycle from `is_enabled` rather than `status`. + +### 2. Run the operator-surface and shared-helper regression pack + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/DirectoryTenantsTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php +``` + +Expected outcome: +- Provider list, provider detail, provider edit, tenant provider summaries, system directory summaries, tenant global search, provider-connection search exclusion, and DB-only rendering paths show lifecycle, consent, and verification without legacy status or health language. +- System-directory list rollups count only each tenant's default Microsoft provider connection, while detail rows stay canonical for every rendered connection. + +### 3. Run the writer-cutover regression pack + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/MvpProviderScopeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingProviderConnectionTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php +``` + +Expected outcome: +- Direct resource create, health checks, onboarding, consent bootstrap, and verification start flows persist only `is_enabled`, `consent_status`, `verification_status`, and diagnostics. +- No writer recreates `status` or `health_status`. + +### 4. Run the audit and badge regression pack + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionConsentAuditTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BooleanEnabledBadgesTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BadgeCatalogTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php +``` + +Expected outcome: +- Audit metadata and lifecycle-mutation regressions record lifecycle changes without reintroducing legacy `from_status` or `to_status` fields. +- Provider surfaces reuse centralized badge mappings for lifecycle, consent, and verification only. + +### 5. Update helpers and run contradiction coverage + +Implementation requirement: +- Update `database/factories/ProviderConnectionFactory.php` and `tests/Pest.php` so default provider fixtures use `is_enabled`, `consent_status`, and `verification_status` only. +- Add contradiction coverage proving that disabled plus consent granted and disabled plus `verification_status = healthy` remain separate truths. +- Add `tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php` so targeted provider-state runtime files cannot reference removed provider columns again. + +Expected outcome: +- Shared test scaffolding stops recreating removed fields. +- Canonical-state separation is enforced by tests instead of by convention. + +### 6. Format touched files + +```bash +cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent +``` + +Expected outcome: +- All touched implementation files match project formatting. + +### 7. Apply the final schema state + +```bash +cd apps/platform && ./vendor/bin/sail artisan migrate +``` + +Expected outcome: +- The final drop migration has been applied and the verification pack runs against the post-drop schema state. + +### 8. Run the focused final verification pack + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/MvpProviderScopeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingProviderConnectionTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ManagedTenantOnboardingWizardTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/TenantScopingTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/System/Spec114/DirectoryTenantsTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionConsentAuditTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BooleanEnabledBadgesTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BadgeCatalogTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/ProviderConnectionBadgesTest.php +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php +``` + +Expected outcome: +- The cutover works across runtime gates, writes, shared operator surfaces, audits, and badges without any dependency on removed legacy provider columns. + +### 9. Perform the residual legacy-state sweep + +Implementation requirement: +- Run the new residual guard test. +- Search targeted provider-state runtime paths for the removed column names to confirm there is no surviving dependency in resolvers, jobs, resources, controllers, helpers, factories, or shared Blade views. + +```bash +cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php +``` + +Expected outcome: +- No targeted provider-state file still reads, writes, filters, or renders `status` or `health_status` for `ProviderConnection`. + +## Manual Smoke Check + +1. Open `/admin/provider-connections` and confirm the default-visible state columns are `Lifecycle`, `Consent`, and `Verification`. +2. Open a disabled connection that still has granted consent and confirm the page shows `Lifecycle: Disabled` without losing the positive consent state. +3. Open a connection with granted consent and blocked verification and confirm the page shows the blocker as verification truth rather than as a synthetic legacy error state. +4. Open `/admin/tenants/{tenant}` and confirm the shared provider summary no longer renders legacy status or health diagnostics as peer truths. +5. Open `/system/directory/tenants` and `/system/directory/tenants/{tenant}` and confirm read-only summaries align with the same lifecycle, consent, and verification story shown in `/admin`. +6. Trigger enable, disable, consent, and verification flows and confirm audit history and notifications remain intact. +7. Trigger admin global search in workspace context and confirm tenants remain searchable while provider connections remain excluded. +8. Repeat one admin-plane URL as an out-of-scope actor and confirm deny-as-not-found behavior remains unchanged. + +## Release Completion Criteria + +- Schema no longer contains `status` or `health_status` on `provider_connections`. +- Runtime readers and writers no longer mention removed fields. +- Legacy badge domains for provider connection status and health are deleted. +- Shared helpers and views no longer expose removed legacy keys. +- Focused regression coverage and the residual guard prove the hard cut is complete. \ No newline at end of file diff --git a/specs/188-provider-connection-state-cleanup/research.md b/specs/188-provider-connection-state-cleanup/research.md new file mode 100644 index 00000000..7767b8ca --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/research.md @@ -0,0 +1,74 @@ +# Phase 0 Research: Canonical Provider Connection State Cleanup + +## Decision: Persist lifecycle as a single `is_enabled` boolean and present it as the `Lifecycle` dimension + +**Rationale**: The only provider-state behavior still carried uniquely by legacy `status` is the explicit enabled or disabled gate. Consent and verification already have canonical enums and should not absorb administrative disablement. A boolean `is_enabled` is the narrowest persisted truth that preserves the current `disabled` behavior without inventing a broader lifecycle framework or another enum class. + +**Alternatives considered**: +- Add a new `lifecycle_status` enum class: rejected because current-release behavior is binary and a boolean is narrower. +- Keep deriving disablement from legacy `status`: rejected because it preserves the dual-truth model the spec is removing. +- Encode disablement inside `verification_status`: rejected because administrative pause is not a verification outcome. + +## Decision: Keep `ProviderConsentStatus` and `ProviderVerificationStatus` as the only consent and verification truths + +**Rationale**: The current schema already stores `consent_status` and `verification_status`, and the runtime already uses them in onboarding, consent, and health-check flows. The cleanup should remove the competing legacy layer, not introduce a new readiness or health family. Consent continues to answer permission state, verification continues to answer what the latest check proves, and lifecycle remains separate. + +**Alternatives considered**: +- Introduce a new combined provider readiness status: rejected because it would collapse three distinct operator questions back into one ambiguous state. +- Preserve legacy `status` as a derived compatibility field: rejected because the spec calls for a hard cut with no compatibility tail. + +## Decision: Remove legacy projection outputs and trim `ProviderConnectionStateProjector` to canonical verification-outcome logic only + +**Rationale**: `ProviderConnectionStateProjector::project()` and `projectForConnection()` exist to emit `status` and `health_status`, preserve `disabled`, and keep legacy fields alive across multiple writers. That responsibility disappears after cutover. The only reusable logic worth retaining is the current `projectVerificationOutcome()` reasoning that translates a health-check result plus current consent state into canonical consent and verification outcomes and error metadata. Retaining that narrow derivation avoids duplicating reason-code handling while still eliminating legacy field projection. + +**Alternatives considered**: +- Keep the projector and repoint it to emit a new canonical DTO: rejected because direct canonical persistence is simpler and a new DTO layer is unnecessary. +- Inline all verification-outcome logic into each writer: rejected because the consent and verification reason-code rules already exist in one place and are reused. + +## Decision: Replace legacy `HealthResult` transport fields with canonical verification output + +**Rationale**: `HealthResult` currently transports legacy `status` and `healthStatus`, and `MicrosoftProviderHealthCheck` manufactures those values purely so downstream code can keep writing the removed columns. The contract should instead expose canonical verification intent and diagnostics only: `verificationStatus`, `reasonCode`, `message`, and `meta`. That keeps health-check output aligned with the final storage model and removes a legacy vocabulary bridge from the contract layer. + +**Alternatives considered**: +- Keep `status` and `healthStatus` in `HealthResult` until the final migration: rejected because that prolongs legacy semantics inside the runtime contract layer. +- Replace `HealthResult` with a new service or result hierarchy: rejected because the existing contract can be narrowed without new abstraction. + +## Decision: Treat reader cutover as the first breaking-change phase and schema removal as the last + +**Rationale**: The current runtime still reads legacy fields in `ProviderConnectionResolver`, `ScanEntraAdminRolesJob`, `ProviderConnectionResource` action visibility, tenant provider summaries, system directory queries, and shared Blade views. Dropping columns before those reads move would break provider resolution, action gating, and page rendering immediately. The correct order is lifecycle foundation plus the one-time disabled-state backfill first, canonical reader cutover across shared helpers, operator surfaces, and runtime gates second, canonical writer and transport cleanup third, badge and helper cleanup fourth, residual test-scaffolding cleanup fifth, and schema removal last. + +**Alternatives considered**: +- Remove columns immediately and fix failures opportunistically: rejected because the affected reads span resolvers, background jobs, Livewire actions, and read-only system pages. +- Dual-read or dual-write for a compatibility period: rejected because the feature explicitly forbids a compatibility tail. + +## Decision: Recompute system-directory health summaries from canonical verification and permission truth + +**Rationale**: The system directory currently counts provider issues from `health_status` and renders provider rows from `status` and `health_status`. Those summaries must move to canonical verification plus existing permission signals so `/system` remains semantically aligned with `/admin`. Tenant-level list rollups count only the default Microsoft provider connection for each tenant; non-default connections may still appear on the read-only detail page but do not affect list rollup badges. The existing `SystemHealth` badge domain can stay; only its input contract changes. + +**Alternatives considered**: +- Leave `/system` on legacy diagnostics while `/admin` becomes canonical: rejected because the same connection would tell two different stories depending on the plane. +- Introduce a new system-only provider-health projection: rejected because that would add another layer of derived truth. + +## Decision: Reuse existing centralized badge infrastructure and retire legacy provider badge domains + +**Rationale**: The repo already has `BadgeDomain::BooleanEnabled`, `ProviderConsentStatus`, and `ProviderVerificationStatus`. That is enough to render lifecycle, consent, and verification without inventing a provider-lifecycle badge domain. The cleanup should delete `BadgeDomain::ProviderConnectionStatus`, `BadgeDomain::ProviderConnectionHealth`, and their mapper classes, while reusing `BooleanEnabled` for lifecycle and the existing provider consent and verification mappings for the other two dimensions. + +**Alternatives considered**: +- Add a new provider lifecycle badge domain: rejected because `BooleanEnabled` already matches the required enabled or disabled semantics. +- Keep legacy badge domains for diagnostics only: rejected because their continued existence makes reintroduction of legacy truth too easy. + +## Decision: Shared provider-state helpers and views must emit only lifecycle, consent, and verification + +**Rationale**: `TenantResource::providerConnectionState()`, the shared `provider-connection-state.blade.php` entry, and system directory detail views still carry `status` and `health_status` keys. Those helpers are the most likely place for accidental legacy fallback because many surfaces share them. Their contract should change directly to lifecycle, consent, verification, and diagnostics such as `last_health_check_at` and `last_error_reason_code`. + +**Alternatives considered**: +- Leave legacy keys in helper arrays and stop rendering them: rejected because hidden compatibility keys would keep inviting silent legacy reads. +- Create a new presenter for canonical provider state: rejected because the existing helper contract can be narrowed without adding a new presentation layer. + +## Decision: Update factories and Pest helpers before final schema removal and add contradiction and residual-guard coverage + +**Rationale**: `ProviderConnectionFactory` and the shared Pest helper still force `status = connected` and `health_status = ok` when constructing default provider fixtures. Those helpers will recreate legacy coupling unless they change before the drop migration lands. The regression suite also needs explicit contradiction scenarios, such as disabled plus consent granted and disabled plus verification healthy, so lifecycle separation is proven after the cutover. A final residual guard should fail if provider-state runtime paths still reference removed columns. + +**Alternatives considered**: +- Let individual tests update as they fail: rejected because the helper layer would keep reintroducing removed fields across unrelated suites. +- Rely only on migration failure to find remaining references: rejected because many references live in PHP helpers, Blade views, and query builders that can survive until runtime. \ No newline at end of file diff --git a/specs/188-provider-connection-state-cleanup/spec.md b/specs/188-provider-connection-state-cleanup/spec.md new file mode 100644 index 00000000..79561760 --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/spec.md @@ -0,0 +1,248 @@ +# Feature Specification: Canonical Provider Connection State Cleanup + +**Feature Branch**: `[188-provider-connection-state-cleanup]` +**Created**: 2026-04-09 +**Status**: Draft +**Input**: User description: "Spec 188 — Canonical Provider Connection State Cleanup" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace + tenant + platform directory alignment +- **Primary Routes**: + - `/admin/provider-connections` + - `/admin/provider-connections/{record}` + - `/admin/provider-connections/{record}/edit` + - `/admin/tenants` + - `/admin/tenants/{tenant}` + - existing tenant-context admin flows under `/admin/t/{tenant}/...` + - `/system/directory/tenants` + - `/system/directory/tenants/{tenant}` +- **Data Ownership**: Provider connection state remains tenant-owned truth on provider connection records scoped by workspace and tenant. This feature removes the legacy provider status and health storage from that tenant-owned truth and leaves one explicit lifecycle truth beside the existing consent and verification truth. System directory surfaces remain read-only views over the same tenant-owned truth; no compatibility mirror, summary artifact, or second state ledger is introduced. +- **RBAC**: The admin plane under `/admin` and tenant-context admin routes under `/admin/t/{tenant}/...` continue to require workspace entitlement plus tenant entitlement. The platform directory under `/system` continues to require platform directory access and must not inherit admin-plane membership. Non-members remain deny-as-not-found (`404`). In-scope actors missing the required capability remain forbidden (`403`). Existing server-side capability checks, confirmations, and audit behavior for provider mutations remain authoritative. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Workspace-context provider connection surfaces remain prefiltered to the current tenant when tenant context or an explicit tenant filter is active. Platform directory surfaces under `/system` never inherit tenant context and remain platform-scoped. +- **Explicit entitlement checks preventing cross-tenant leakage**: Admin-plane queries, summaries, shared provider-state entries, and drill-downs must continue to scope provider connections to the active workspace and entitled tenant set only. Any platform-directory link into an admin-plane tenant surface must still respect admin-plane workspace and tenant entitlement and fail as `404` when that entitlement is absent. + +## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)* + +| Surface | Surface Type | 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 | +|---|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections list | CRUD / List-first Resource | Full-row click into provider connection detail | required | Header `New connection`; non-primary mutations remain in `More` | Existing lifecycle and credential mutations remain secondary in `More` and keep confirmation where destructive-like | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Active workspace, current tenant filter, provider identity, default marker | Provider Connections / Provider Connection | Lifecycle, consent, and verification shown as separate dimensions; no legacy status or health language | none | +| Provider connection detail | Detail / view-first resource | Dedicated detail page | forbidden | Existing detail actions stay grouped in the header area | Existing lifecycle and credential mutations stay grouped and confirmed | `/admin/provider-connections` | `/admin/provider-connections/{record}` | Tenant identity, provider identity, connection type, default marker | Provider Connection | Lifecycle, consent, and verification remain distinct while diagnostics stay secondary | none | +| Provider connection edit | Edit surface | Dedicated edit page | forbidden | Save/cancel plus existing grouped helper actions | Existing lifecycle and credential mutations stay grouped and confirmed | `/admin/provider-connections` | `/admin/provider-connections/{record}/edit` | Tenant identity, provider identity, connection type, default marker | Provider Connection | Current lifecycle, consent, and verification context is visible without legacy fields | none | +| Tenant list | CRUD / List-first Resource | Full-row click into tenant detail | required | Existing tenant helper and workflow actions remain secondary | Existing tenant destructive actions remain secondary and unchanged | `/admin/tenants` | `/admin/tenants/{tenant}` | Active workspace, tenant identity, environment | Tenants / Tenant | Any provider-state summary must come only from canonical lifecycle, consent, and verification truth | none | +| Tenant detail and tenant-context admin provider summaries | Detail / dashboard / workflow surfaces | Existing tenant page or tenant-context page itself | forbidden | Existing tenant and provider navigation actions remain secondary | Existing destructive tenant or provider mutations remain grouped and confirmed | `/admin/tenants` and existing `/admin/t/{tenant}/...` routes | `/admin/tenants/{tenant}` plus existing tenant-context destinations | Active workspace, tenant context, provider identity | Tenant / Provider Connection | Provider state is presented as lifecycle, consent, and verification only, with no shadow diagnostic truth | none | +| System tenant directory list | Read-only registry / list | Full-row click into system tenant detail | required | No secondary actions are introduced | none | `/system/directory/tenants` | `/system/directory/tenants/{tenant}` | Platform context, workspace label, tenant identity | Tenants / Tenant | Read-only health summaries must not depend on legacy provider status or health fields | none | +| System tenant detail | Read-only detail surface | Dedicated detail page | forbidden | Existing navigation links remain the only secondary affordances | none | `/system/directory/tenants` | `/system/directory/tenants/{tenant}` | Platform context, workspace label, tenant identity | Tenant / Provider Connection | Provider rows show only lifecycle, consent, and verification truth | none | + +## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)* + +| Surface | Primary Persona | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---| +| Provider connections list | Tenant operator or tenant manager | List | Which connections are operable, consented, and technically verified right now? | Provider identity, default marker, lifecycle, consent, verification | Last check recency, raw provider errors, low-level identifiers | lifecycle, consent, verification | Existing provider mutations only; no new mutation type | Open provider connection, New connection | Disable connection, credential deletion or rotation, and similar existing lifecycle mutations remain secondary and confirmed | +| Provider connection detail | Tenant operator or tenant manager | Detail | Can this connection be used, is consent present, and what does the last verification actually prove? | Lifecycle, consent, verification, provider identity, default designation | Raw provider response details, timestamps, supporting diagnostics | lifecycle, consent, verification | Existing provider mutations only | Check connection, Grant admin consent, Edit | Existing lifecycle and credential mutations remain grouped, capability-gated, and confirmed | +| Provider connection edit | Tenant manager | Edit | What can I safely change without confusing configuration, consent, and verification truth? | Editable settings plus current lifecycle, consent, and verification context | Low-level technical metadata only when explicitly needed | lifecycle, consent, verification | Existing provider configuration mutations only | Save changes, Cancel | Existing lifecycle and credential mutations remain grouped, capability-gated, and confirmed | +| Tenant list | Workspace operator | List | Which tenants have provider connections that need attention, and is that attention about lifecycle, consent, or verification? | Tenant identity plus any bounded provider-state summary derived from canonical truth | Legacy-style provider diagnostics are not allowed as hidden primary truth | lifecycle, consent, verification when summarized | none; read-first list | Open tenant | Existing tenant destructive actions remain outside this spec and stay secondary | +| Tenant detail and tenant-context admin provider summaries | Tenant operator | Detail / workflow | What is the provider state for this tenant, and which dimension needs action? | Lifecycle, consent, verification, next-step guidance | Supporting diagnostics such as timestamps or detailed messages | lifecycle, consent, verification | Existing tenant and provider mutations only | Open provider connection, Grant admin consent, Start verification where already available | Existing destructive tenant or provider actions remain confirmed and capability-gated | +| System tenant directory list | Platform operator | Read-only list | Which tenants show provider-connection concerns without importing a second truth model? | Tenant identity, workspace, any read-only summary derived from canonical truth | Low-level health breakdowns remain secondary if shown at all | lifecycle, consent, verification when summarized | none | Open tenant detail | none | +| System tenant detail | Platform operator | Read-only detail | Does the same provider connection tell the same lifecycle, consent, and verification story here as it does in admin surfaces? | Provider rows with lifecycle, consent, verification | Supporting diagnostics and recent runs | lifecycle, consent, verification | none | Open admin tenant when legitimately available, View runs when already available | none | + +## Proportionality Review *(mandatory when structural complexity is introduced)* + +- **New source of truth?**: yes +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: no +- **New enum/state/reason family?**: yes +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: Provider connections still carry two competing state languages. The same connection can appear disabled, connected, healthy, consented, or verified depending on which legacy field or newer field a runtime path or page reads. That creates incorrect gates, inconsistent operator guidance, and semantic confusion. +- **Existing structure is insufficient because**: Consent truth and verification truth already exist, but the remaining legacy status and health fields still persist, still influence runtime behavior, and still appear in multiple surfaces. The leftover administrative semantics, especially enabled or disabled behavior, do not belong inside consent or verification and cannot be modeled cleanly by leaving the legacy layer half-active. +- **Narrowest correct implementation**: Remove legacy provider status and health truth completely, keep consent and verification as the canonical truth for their own dimensions, and make lifecycle explicit as the only additional canonical dimension required to preserve real enabled or disabled behavior. +- **Ownership cost**: This adds one focused state-model cleanup across persistence, runtime readers and writers, contracts, badges, filters, operator surfaces, and regression coverage, but it avoids carrying an indefinite compatibility tail. +- **Alternative intentionally rejected**: Keeping legacy fields as compatibility mirrors, or forcing disabled or enabled semantics into consent or verification, was rejected because both options preserve parallel truth and semantic drift. +- **Release truth**: current-release hard cutover and canonical domain cleanup + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Read One Canonical Provider State Language (Priority: P1) + +An operator can open a provider connection from provider, tenant, or system surfaces and understand separately whether the connection is operable, whether consent is present, and what verification currently proves. + +**Why this priority**: The primary user risk is operator misread. If the product still presents multiple competing state languages, the domain remains unsafe even before any code-level cleanup is discussed. + +**Independent Test**: Can be fully tested by seeding provider connections with canonical contradiction scenarios such as disabled with granted consent, enabled with missing consent, and enabled with blocked verification, then rendering provider, tenant, and system surfaces to verify that operators only see lifecycle, consent, and verification as the authoritative dimensions. + +**Acceptance Scenarios**: + +1. **Given** a connection is disabled while consent is granted and verification is successful, **When** an operator opens provider detail and system tenant detail, **Then** lifecycle shows the connection as disabled while consent and verification remain separately positive and no legacy labels appear. +2. **Given** a connection is enabled, consent is missing, and verification is unknown, **When** an operator opens provider list or tenant summaries, **Then** the product shows missing consent and unknown verification without substituting a legacy health or status shortcut. + +--- + +### User Story 2 - Trust Runtime Decisions Again (Priority: P1) + +An operator or reviewer can trust that readiness checks, action visibility, onboarding callbacks, verification starts, and health checks all use the same canonical provider truth instead of hidden legacy gates. + +**Why this priority**: UI cleanup alone is not enough. Runtime behavior must stop depending on removed fields or the product will remain semantically inconsistent and operationally brittle. + +**Independent Test**: Can be fully tested by executing create, onboarding, consent, verification, health-check, and mutation flows while asserting that all reads, writes, and queries depend only on lifecycle, consent, and verification truth. + +**Acceptance Scenarios**: + +1. **Given** a connection is administratively disabled, **When** runtime gates determine whether an operation may proceed, **Then** that decision uses canonical lifecycle truth rather than legacy status fields. +2. **Given** consent callbacks, verification starts, or health checks update a connection, **When** the write completes, **Then** only canonical lifecycle, consent, and verification truth is persisted and no legacy field is written. +3. **Given** resolver, list, and aggregate queries run after the cleanup, **When** provider-state logic executes, **Then** there are no active filters or gates based on removed legacy status or health fields. + +--- + +### User Story 3 - Finish The Hard Cut Without Residual Tail (Priority: P2) + +The product owner can release this cleanup knowing that schema, badges, filters, contracts, helpers, and tests no longer preserve a hidden compatibility layer for removed provider state. + +**Why this priority**: The requested hard cut only succeeds if the repo no longer contains product-relevant legacy truth or a path that silently recreates it later. + +**Independent Test**: Can be fully tested by applying the final schema, running focused regression coverage, and performing residual checks that prove legacy provider status or health truth no longer appears in runtime logic, presentation helpers, filters, factories, or tests. + +**Acceptance Scenarios**: + +1. **Given** the final migration has been applied, **When** the provider connection schema is inspected, **Then** the legacy status and health fields no longer exist. +2. **Given** provider-focused regression coverage and residual checks run, **When** the release is validated, **Then** no active badge, contract, filter, helper, query, or test depends on legacy provider status or health semantics. + +### Edge Cases + +- A connection remains disabled even though consent is granted and the most recent verification was successful; lifecycle must stay separate from positive consent or verification truth. +- Consent can be revoked or missing while lifecycle remains enabled; the product must not translate that into a lifecycle failure. +- Verification can be unknown, degraded, error, or blocked while lifecycle remains enabled; the product must not translate that into consent truth or administrative disablement. +- The same connection must tell the same lifecycle, consent, and verification story on provider, tenant, and system surfaces even when older code previously summarized it through legacy health. +- Because this is a hard cut with no legacy preservation, missing or removed legacy fields must fail validation during implementation rather than being silently reconstructed through accessors or fallback shims. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature changes existing provider-state write paths and existing provider-state reads that already participate in onboarding, consent, verification, and health-check behavior. It introduces no new Microsoft Graph endpoint family, no new provider type, and no new run family. Existing preview, confirmation, audit, tenant-isolation, and run-observability rules remain in force. The change is limited to what provider connection state is persisted, projected, queried, gated, and rendered. + +**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The spec introduces one necessary canonical lifecycle dimension because current-release provider truth already requires a separate administrative enabled or disabled answer that neither consent nor verification can carry correctly. No new table, artifact, or abstraction layer is added. The bias is replacement before layering: remove legacy truth, keep existing consent and verification truth, and introduce only the explicit lifecycle dimension required to preserve real behavior. + +**Constitution alignment (OPS-UX):** Existing verification and health-check runs keep their current `OperationRun` ownership, notification, and observability rules. This cleanup does not introduce a new run type or new progress surface. Regression coverage must prove that state cleanup does not bypass existing run ownership or reporting contracts in the flows that already use them. + +**Constitution alignment (RBAC-UX):** This feature spans tenant/admin `/admin`, tenant-context admin routes under `/admin/t/{tenant}/...`, and platform `/system` read surfaces. Cross-plane access remains deny-as-not-found. Non-members or actors outside workspace or tenant entitlement remain `404`. In-scope actors missing required capability remain `403`. Server-side authorization for lifecycle mutations, onboarding flows, consent callbacks, and verification starts stays with the existing policy and capability registry. Provider connections remain non-globally-searchable, tenant global search behavior is unchanged and tenant-safe, and existing destructive-like actions such as disabling a connection or deleting credentials remain confirmation-gated. Coverage must include at least one positive and one negative authorization regression on touched provider-state mutations or surfaces. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. Authentication handshake behavior is unchanged. + +**Constitution alignment (BADGE-001):** Badge semantics remain centralized. The cleanup removes legacy provider status and health badge domains and leaves lifecycle, consent, and verification to be rendered through shared badge mappings rather than page-local labels or colors. Regression coverage must prevent legacy badge domains or ad-hoc provider-state mappings from reappearing. + +**Constitution alignment (UI-FIL-001):** Existing Filament tables, infolists, sections, filters, and shared provider-state primitives remain the presentation base. The cleanup must avoid local replacement markup or page-local status color systems. No exception is expected. + +**Constitution alignment (UI-NAMING-001):** Operator-facing vocabulary must consistently use `Lifecycle`, `Consent`, and `Verification` when describing provider connection state. Existing verbs such as `Enable connection`, `Disable connection`, `Grant admin consent`, `Start verification`, and `Check connection` remain acceptable because they correspond to one dimension each. Surfaces must not expose labels such as `Legacy status`, `Legacy health`, `Diagnostic status`, or `Diagnostic health` as active product language. + +**Constitution alignment (UI-CONST-001 / UI-SURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001):** The affected surfaces remain list-first provider and tenant resources, detail-first provider and tenant pages, and read-only platform directory pages. Each surface keeps one primary inspect model, existing secondary actions stay in their current regions, destructive actions stay secondary, routes stay canonical, and the critical default-visible truth becomes lifecycle, consent, and verification only. + +**Constitution alignment (OPSURF-001):** Default-visible content must stay operator-first and avoid raw implementation detail. Diagnostics remain secondary and explicitly subordinate to the three canonical dimensions. No new mutation scope is introduced; existing actions continue to communicate whether they affect TenantPilot state, Microsoft tenant state, or both, and their safe-execution pattern remains unchanged. + +**Constitution alignment (UI-SEM-001 / LAYER-001 / TEST-TRUTH-001):** Direct mapping from canonical lifecycle, consent, and verification truth to UI is sufficient once the legacy layer is removed. The feature must not introduce a new presenter framework, shadow diagnostics truth, or compatibility wrapper. Tests focus on business consequences: what operators see, what runtime paths decide, and what persistence shape remains. + +**Constitution alignment (Filament Action Surfaces):** The Action Surface Contract remains satisfied. Affected admin surfaces keep one primary inspect model, no redundant `View` actions are added, no empty action groups are introduced, and destructive-like actions retain their required placement and confirmation rules. UI-FIL-001 is satisfied with no approved exception. + +**Constitution alignment (Global Search / Rendering Safety):** `TenantResource` global search remains tenant-safe and unchanged, `ProviderConnectionResource` remains non-globally-searchable, and touched provider, tenant, and system surfaces must preserve DB-only rendering without introducing per-row remote calls. Coverage must include explicit search-safety and DB-only regressions for touched resources and summaries. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** No new screen type is introduced. Existing provider list screens retain search, sort, and canonical filters. Detail pages continue to use view-first patterns and infolist-style status presentation. Edit pages continue to use structured form sections and save/cancel actions. Empty states remain specific and single-CTA where they already exist. The required change is that state presentation and core filters align to lifecycle, consent, and verification rather than legacy provider status or health. + +### Hard-Cutover Assumptions + +- No relevant existing data requires preserving after cutover. A one-time migration backfill may translate legacy `status = disabled` into `is_enabled = false`, but legacy provider status and health values do not remain active product truth. +- No dual-read, dual-write, compatibility accessor, compatibility mutator, or fallback shim is allowed. +- Runtime readers and writers must be fully moved to canonical lifecycle, consent, and verification before legacy columns are removed. +- Administrative lifecycle must remain explicit and must not be hidden inside consent or verification semantics. + +### Functional Requirements + +- **FR-188-001**: Provider connection state MUST be expressed through exactly three business dimensions: lifecycle, consent, and verification. +- **FR-188-002**: Lifecycle MUST have one explicit canonical truth on the provider connection record and MUST carry the current administrative enabled or disabled semantics that do not belong to consent or verification. +- **FR-188-003**: Consent truth MUST rely only on `consent_status` and MUST NOT inherit lifecycle or legacy status semantics. +- **FR-188-004**: Verification truth MUST rely only on `verification_status` and MUST NOT inherit lifecycle or legacy health semantics. +- **FR-188-005**: The legacy provider connection fields `status` and `health_status` MUST be removed from the active domain model, the persisted schema, and any compatibility layer. +- **FR-188-006**: No accessor, mutator, cast, fallback, projection shim, or presentation shim may simulate removed legacy provider fields after cutover. +- **FR-188-007**: Shared provider-state outputs and projections MUST return only lifecycle, consent, and verification information and MUST exclude legacy fields. +- **FR-188-008**: Resolver decisions, readiness checks, action visibility, operator guidance, and aggregate logic MUST use only lifecycle, consent, and verification truth. +- **FR-188-009**: No active product query may filter, aggregate, or gate on legacy provider status or legacy provider health. +- **FR-188-010**: Create flows, onboarding flows, consent callbacks, verification starts, health checks, mutation services, and similar write paths MUST persist only canonical truth and MUST NOT dual-write legacy fields. +- **FR-188-011**: Health and verification contracts MUST carry only the information needed to derive canonical consent and verification truth plus supporting diagnostics, and MUST drop vocabulary whose sole purpose was legacy persistence or legacy display. Lifecycle remains explicit record truth via `is_enabled` and MUST NOT be derived from health-check transport. +- **FR-188-012**: Operator-facing provider, tenant, and system surfaces MUST remove legacy status and health fields, labels, hidden entries, toggles, and filters and MUST display one consistent canonical state language. +- **FR-188-013**: Diagnostics MAY show supporting facts such as recency, errors, or next steps, but they MUST NOT create a second competing provider truth model. +- **FR-188-014**: Shared badge and mapping layers MUST retire legacy provider status and health domains and present only canonical lifecycle, consent, and verification states through centralized mappings. +- **FR-188-015**: Tenant-facing, provider-facing, and system-facing views of the same connection MUST remain semantically aligned and show the same lifecycle, consent, and verification truth. +- **FR-188-016**: Disabled or enabled behavior MUST remain intact through the canonical lifecycle dimension and MUST NOT be reinterpreted as consent granted or denied or as verification healthy or unhealthy. +- **FR-188-017**: Factories, helpers, regression scaffolds, and automated tests MUST stop setting, asserting, or filtering on legacy provider status or health truth. +- **FR-188-018**: The cutover MUST complete in ordered phases: lifecycle foundation and one-time disabled-state backfill first, canonical reader cutover across shared helpers, operator surfaces, and runtime gates second, canonical writer and transport cleanup third, badge and helper cleanup fourth, residual test-scaffolding cleanup fifth, and schema removal last. +- **FR-188-021**: Touched provider, tenant, and system surfaces MUST preserve DB-only rendering and MUST NOT introduce new per-row remote calls while the canonical lifecycle, consent, and verification state is adopted. +- **FR-188-019**: Existing provider lifecycle and credential mutations MUST keep their current server-side authorization, confirmation, and audit behavior while evaluating eligibility from canonical lifecycle, consent, and verification only. +- **FR-188-020**: The release is not complete unless release validation shows zero active runtime, UI, badge, contract, query, helper, test, or schema coupling to legacy provider status or health semantics. + +## UI Action Matrix *(mandatory when Filament is changed)* + +| 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 | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections list | `/admin/provider-connections` | `New connection` | Clickable row to provider detail | Existing `More` actions remain; no new primary row mutation is introduced | none | Existing `New connection` CTA remains | n/a | n/a | existing only | Action Surface Contract remains satisfied. Default-visible columns and filters change to canonical lifecycle, consent, and verification. Legacy provider status and health filters are removed. | +| Provider connection detail | `/admin/provider-connections/{record}` | No new header action labels; existing grouped actions remain | Dedicated detail page | n/a | none | n/a | Existing grouped actions such as `Grant admin consent`, `Edit`, `Check connection`, and `View last check run` remain | n/a | existing only | Detail presentation removes legacy entries and shows lifecycle, consent, and verification as separate truths. | +| Provider connection edit | `/admin/provider-connections/{record}/edit` | No new header action labels | Dedicated edit page | n/a | none | n/a | Existing grouped lifecycle and credential actions remain secondary | Save and Cancel remain unchanged | existing only | Edit context may show canonical truth for operator context, but not legacy fields or legacy filters. | +| Tenant list | `/admin/tenants` | Existing tenant entry and onboarding actions remain unchanged | Clickable row to tenant detail | Existing helper and workflow actions remain secondary | Existing grouped tenant bulk actions remain unchanged | Existing empty-state CTA remains unchanged | n/a | n/a | existing only | Any provider-state summary or filter on the list must derive from canonical lifecycle, consent, and verification only. | +| Tenant detail and tenant-context admin provider summaries | `/admin/tenants/{tenant}` and existing `/admin/t/{tenant}/...` routes | No new action labels required | Dedicated tenant page or tenant-context page | n/a | none added by this spec | n/a | Existing tenant and provider navigation actions remain unchanged | Existing save/cancel behavior remains unchanged where edit exists | existing only | Shared provider-state entries, onboarding guidance, and provider summaries remove legacy status or health semantics and keep the three canonical dimensions distinct. | +| System tenant directory list | `/system/directory/tenants` | none | Clickable row to system tenant detail | none | none | Existing empty-state guidance remains | n/a | n/a | no new audit event | Read-only system directory. Any summary health signal must stop depending on legacy provider status or health. | +| System tenant detail | `/system/directory/tenants/{tenant}` | none | Dedicated detail page | none | none | n/a | Existing read-only navigation links remain if already present | n/a | no new audit event | Provider rows must use canonical lifecycle, consent, and verification only and must align with admin-plane truth. | + +### Key Entities *(include if feature involves data)* + +- **Provider connection lifecycle truth**: The canonical answer to whether the connection is administratively operable or intentionally disabled. +- **Provider connection consent truth**: The canonical answer to whether required consent is present, missing, revoked, or otherwise not satisfied. +- **Provider connection verification truth**: The canonical answer to what the latest verification currently proves, such as healthy, unknown, degraded, error, or blocked. +- **Provider connection state projection**: The derived state representation consumed by queries and surfaces that must mirror only lifecycle, consent, and verification truth without introducing an extra status layer. +- **Provider diagnostics**: Supporting information such as timestamps, errors, or next-step guidance that may help interpretation but must not become a fourth truth dimension. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-188-001**: Release validation shows 100% of targeted provider, tenant, and system surfaces using lifecycle, consent, and verification language only, with zero visible legacy provider status or health labels. +- **SC-188-002**: In contradictory seeded scenarios, 100% of targeted surfaces let operators distinguish lifecycle, consent, and verification without one dimension being implied by another. +- **SC-188-003**: All targeted create, onboarding, consent, verification, health-check, and mutation flows complete release validation without reading, writing, or querying legacy provider status or health fields. +- **SC-188-004**: Final release validation confirms the provider connection record shape contains one explicit lifecycle truth alongside consent and verification truth and no longer contains legacy provider status or health fields. +- **SC-188-005**: Residual checks and focused regression coverage report zero active badge, contract, filter, helper, factory, or test paths that reintroduce legacy provider status or health semantics. +- **SC-188-006**: In cross-surface comparison validation, 100% of admin and system views of the same provider connection show the same lifecycle, consent, and verification story. +- **SC-188-007**: `TenantResource` global search remains tenant-safe, `ProviderConnectionResource` remains excluded from global search, and touched provider, tenant, and system surfaces retain DB-only rendering guarantees after the cleanup. + +## Assumptions + +- There are no relevant historic or production data obligations that require preserving or interpreting legacy provider status or health values after cutover. +- Existing consent and verification truth is already authoritative enough to remain the canonical truth for those dimensions. +- Existing enabled or disabled behavior is real product behavior and must survive as explicit lifecycle truth rather than being collapsed into consent or verification. +- Existing authorization, audit, and run-observability behavior remains authoritative; this spec changes the provider-state model and dependent behavior, not the surrounding security model. + +## Non-Goals + +- General provider-registry redesign +- A new diagnostics score or replacement health score +- Historical legacy-data support, backfill, or compatibility reads +- A broader credential redesign beyond what the provider-state cleanup requires +- Dashboard, portfolio, or onboarding redesign outside the touched provider-state surfaces +- Changes in other domains that do not directly depend on provider connection state truth + +## Dependencies + +- Existing provider connection records and tenant scoping remain the canonical persistence base for provider-state truth. +- Existing consent, onboarding, verification, and health-check flows remain the producers of canonical provider-state updates. +- Existing centralized badge and shared provider-state presentation helpers remain the single presentation layer after legacy badge domains are removed. +- Existing admin-plane provider and tenant surfaces and existing system directory surfaces remain the operator-facing consumers that must stay aligned. +- Focused regression coverage across runtime truth, UI truth, badge semantics, contracts, schema, and authorization remains required to keep the cleanup from regressing. + +## Definition of Done + +- Legacy provider `status` and `health_status` fields are absent from the provider connection schema and active domain model. +- One explicit lifecycle truth exists beside consent and verification truth. +- Consent is modeled only through `consent_status`. +- Verification is modeled only through `verification_status`. +- No runtime decision reads legacy provider status or health fields. +- No runtime write persists legacy provider status or health fields. +- Shared provider-state outputs, contracts, badges, filters, and operator surfaces use only lifecycle, consent, and verification truth. +- Tenant, provider, and system surfaces show the same canonical provider-state language. +- Tests, factories, and helpers no longer set or expect legacy provider status or health truth. +- Residual validation finds no active runtime, UI, badge, contract, query, helper, test, or schema coupling to legacy provider status or health semantics. diff --git a/specs/188-provider-connection-state-cleanup/tasks.md b/specs/188-provider-connection-state-cleanup/tasks.md new file mode 100644 index 00000000..4e6c3e62 --- /dev/null +++ b/specs/188-provider-connection-state-cleanup/tasks.md @@ -0,0 +1,207 @@ +# Tasks: Canonical Provider Connection State Cleanup + +**Input**: Design documents from `/specs/188-provider-connection-state-cleanup/` +**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/provider-connection-state-cleanup.openapi.yaml`, `quickstart.md` + +**Tests**: Required. Write or update Pest coverage before each behavior change and keep Sail-first verification focused. + +**Organization**: Tasks are grouped by user story so each story can be implemented and validated independently. + +## Phase 1: Setup (Shared Regression Scaffolding) + +**Purpose**: Create the focused regression entry points and residual guardrails for the hard-cut cleanup before changing runtime or schema behavior. + +- [X] T001 [P] Create the residual legacy-state Pest guard in `apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php` +- [X] T002 [P] Add Spec 188 canonical-state regression entry points in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, and `apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Add the narrow lifecycle truth and shared fixture support that every story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T003 Implement the lifecycle add-and-backfill migration in `apps/platform/database/migrations/2026_04_09_000001_add_is_enabled_to_provider_connections.php` +- [X] T004 [P] Add canonical lifecycle support to the core model and shared test fixtures in `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/database/factories/ProviderConnectionFactory.php`, and `apps/platform/tests/Pest.php` +- [X] T005 [P] Preserve provider and system authorization semantics during the cutover in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/ProviderConnectionsCreateUiEnforcementTest.php`, `apps/platform/tests/Feature/Rbac/EditProviderConnectionUiEnforcementTest.php`, and `apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php` + +**Checkpoint**: The schema, model, fixtures, and access invariants are ready; canonical surface and runtime work can now proceed. + +--- + +## Phase 3: User Story 1 - Read one canonical provider state language (Priority: P1) + +**Goal**: Implement the operator-surface portion of the canonical reader cutover so admin, tenant, and system surfaces show only lifecycle, consent, and verification as the authoritative provider-state language. + +**Independent Test**: Seed provider connections with canonical contradiction scenarios such as disabled with granted consent, enabled with missing consent, and enabled with blocked verification, then render provider, tenant, and system surfaces to verify that operators see lifecycle, consent, and verification only. + +### Tests for User Story 1 + +- [X] T006 [P] [US1] Add admin provider list, detail, edit, and DB-only canonical-state assertions in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php`, and `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php` +- [X] T007 [P] [US1] Add tenant and system canonical provider summary, provider-search exclusion, tenant-search safety, and DB-only assertions in `apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php`, `apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php`, `apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php`, and `apps/platform/tests/Feature/Filament/TenantScopingTest.php` + +### Implementation for User Story 1 + +- [X] T008 [US1] Replace legacy provider list, detail, and edit presentation with lifecycle, consent, and verification in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- [X] T009 [US1] Rework the tenant provider summary helper and shared infolist entry to emit canonical state only in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php` +- [X] T010 [US1] Rework system directory health rollups and provider rows to read canonical state in `apps/platform/app/Filament/System/Pages/Directory/Tenants.php`, `apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php`, and `apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php`, counting only default Microsoft connections in list rollups while keeping detail rows canonical + +**Checkpoint**: Provider, tenant, and system surfaces now speak one canonical provider-state language without visible legacy status or health semantics. The full reader-cutover milestone is complete only after `T014` lands. + +--- + +## Phase 4: User Story 2 - Trust runtime decisions again (Priority: P1) + +**Goal**: Move runtime readers, writers, and transport contracts to lifecycle, consent, and verification so action visibility, onboarding, verification, and health checks stop depending on removed legacy fields. + +**Independent Test**: Execute create, onboarding, consent, verification, health-check, and mutation flows while asserting that all reads, writes, and queries depend only on lifecycle, consent, and verification truth. + +### Tests for User Story 2 + +- [X] T011 [P] [US2] Add canonical reader-gate regressions for lifecycle, consent, and verification in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php`, and `apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php` +- [X] T012 [P] [US2] Add canonical create and writer-flow regressions for resource create, onboarding, consent bootstrap, verification start, and health checks in `apps/platform/tests/Feature/ProviderConnections/MvpProviderScopeTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php`, `apps/platform/tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php`, and `apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php` +- [X] T013 [P] [US2] Add audit and lifecycle-mutation regressions in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionConsentAuditTest.php`, `apps/platform/tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php`, and `apps/platform/tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php` + +### Implementation for User Story 2 + +- [X] T014 [US2] Move resolver and background-job reads to `is_enabled`, `consent_status`, and `verification_status` in `apps/platform/app/Services/Providers/ProviderConnectionResolver.php` and `apps/platform/app/Jobs/ScanEntraAdminRolesJob.php` +- [X] T015 [US2] Remove legacy transport and projection outputs from provider health-check contracts in `apps/platform/app/Services/Providers/Contracts/HealthResult.php`, `apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php`, and `apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php` +- [X] T016 [US2] Update verification and health-check writers to persist canonical state only in `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php` and `apps/platform/app/Services/Verification/StartVerification.php` +- [X] T017 [US2] Update onboarding, consent callback, and provider-mutation writers to persist lifecycle, consent, verification, and diagnostics only in `apps/platform/app/Http/Controllers/AdminConsentCallbackController.php`, `apps/platform/app/Http/Controllers/TenantOnboardingController.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, and `apps/platform/app/Services/Providers/ProviderConnectionMutationService.php` +- [X] T018 [US2] Keep enable or disable actions confirmation-gated and switch lifecycle audit metadata off legacy status in `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` + +**Checkpoint**: Runtime decisions, verification flows, onboarding, and health checks all use canonical lifecycle, consent, and verification truth. + +--- + +## Phase 5: User Story 3 - Finish the hard cut without residual tail (Priority: P2) + +**Goal**: Remove the remaining compatibility surface so schema, badges, helpers, factories, and tests cannot recreate legacy provider state. + +**Independent Test**: Apply the final schema, run focused regression coverage, and prove that no active badge, contract, filter, helper, factory, query, or test depends on legacy provider status or health. + +### Tests for User Story 3 + +- [X] T019 [P] [US3] Add contradiction scenarios for disabled plus granted consent and disabled plus `verification_status = healthy` in `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php`, `apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`, and `apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php` +- [X] T020 [P] [US3] Add legacy-badge and residual-fallback regressions in `apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php`, `apps/platform/tests/Unit/Badges/BooleanEnabledBadgesTest.php`, `apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php`, `apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php`, and `apps/platform/tests/Unit/Badges/BadgeCatalogTest.php` + +### Implementation for User Story 3 + +- [X] T021 [US3] Retire legacy provider badge domains and route lifecycle through shared badge mappings in `apps/platform/app/Support/Badges/BadgeDomain.php`, `apps/platform/app/Support/Badges/BadgeCatalog.php`, `apps/platform/app/Support/Badges/Domains/ProviderConnectionStatusBadge.php`, and `apps/platform/app/Support/Badges/Domains/ProviderConnectionHealthBadge.php` +- [X] T022 [US3] Remove remaining legacy provider-state helper keys, labels, and comments from `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php`, and `apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php` +- [X] T023 [US3] Remove any surviving legacy provider-state references from shared fixtures and targeted runtime paths in `apps/platform/database/factories/ProviderConnectionFactory.php`, `apps/platform/tests/Pest.php`, and files matched by `apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php` +- [X] T024 [US3] Drop `status`, `health_status`, and their indexes from `provider_connections` in `apps/platform/database/migrations/2026_04_09_000002_drop_legacy_provider_state_columns_from_provider_connections.php` + +**Checkpoint**: The hard cut is complete; no active runtime, UI, badge, helper, factory, or schema path can recreate legacy provider state. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Format, verify, and confirm the hard cut with the focused Sail pack and manual smoke checks. + +- [X] T025 Run `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` for touched files under `apps/platform/app/`, `apps/platform/database/`, `apps/platform/resources/views/`, and `apps/platform/tests/` +- [X] T026 Run the focused Spec 188 Sail verification pack from `specs/188-provider-connection-state-cleanup/quickstart.md` +- [X] T027 Execute the manual smoke checklist and residual legacy-state sweep from `specs/188-provider-connection-state-cleanup/quickstart.md` against `/admin/provider-connections`, `/admin/tenants/{tenant}`, `/system/directory/tenants`, and `/system/directory/tenants/{tenant}` +- [X] T028 Validate that the final diff contains no dual-read or dual-write shim, no new provider-state abstraction, and no undeclared persistence beyond `is_enabled` by reviewing `apps/platform/app/Services/Providers/`, `apps/platform/app/Support/Badges/`, `apps/platform/database/migrations/`, and `specs/188-provider-connection-state-cleanup/plan.md` + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; start immediately. +- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user story work. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion. +- **User Story 3 (Phase 5)**: Depends on User Story 1 and User Story 2 completion because it removes the remaining compatibility surface and drops the schema. +- **Polish (Phase 6)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1**: Covers the operator-surface portion of the reader cutover after Phase 2 and is not a standalone implementation-complete slice until `T014` lands. +- **US2**: Independent as a review slice after Phase 2, but writer tasks `T015`-`T018` must follow the canonical reader cutover before the feature is release-ready. +- **US3**: Depends on the finished surface and runtime cutover from US1 and US2. + +### Cross-Story Implementation Sequencing + +- `T008`-`T010` and `T014` together form the canonical reader cutover across shared helpers, operator surfaces, and runtime gates. +- `T015`-`T018` complete the canonical writer and transport cutover and should land only after the reader cutover above is in place. +- `T021`-`T024` are final hard-cut cleanup tasks and depend on both P1 stories being functionally complete. + +### Within Each User Story + +- Write or update the story tests first and confirm they fail for the intended reason. +- Move shared readers before story-specific writers when both are touched by the same cutover. +- Finish story-specific assertions after the implementation lands. +- Keep authorization, confirmation, and audit regressions green before advancing to the next story. + +### Parallel Opportunities + +- `T001` and `T002` can run in parallel. +- `T004` and `T005` can run in parallel after `T003` lands. +- Phase 3 and Phase 4 regression authoring can run in parallel after Phase 2 completes, but implementation sequencing still follows reader cutover before writer cutover. +- `T019` and `T020` can run in parallel after both Phase 3 and Phase 4 are complete. + +--- + +## Parallel Example: User Story 1 + +```bash +# Lock the admin and read-only surface expectations before changing provider-state UI: +Task: T006 Add admin provider list, detail, edit, and DB-only canonical-state assertions in apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php, apps/platform/tests/Feature/Filament/ProviderConnectionsDbOnlyTest.php, and apps/platform/tests/Feature/ProviderConnections/ProviderConnectionViewsDbOnlyRenderingSpec081Test.php +Task: T007 Add tenant and system canonical provider summary, provider-search exclusion, tenant-search safety, and DB-only assertions in apps/platform/tests/Feature/Tenants/TenantProviderConnectionsCtaTest.php, apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php, apps/platform/tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php, and apps/platform/tests/Feature/Filament/TenantScopingTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# Lock reader, writer, and audit behavior before changing runtime provider-state logic: +Task: T011 Add canonical reader-gate regressions in apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php, apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php, and apps/platform/tests/Feature/EntraAdminRoles/ScanEntraAdminRolesJobTest.php +Task: T012 Add canonical writer-flow regressions in apps/platform/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckJobTest.php, apps/platform/tests/Feature/Verification/ProviderConnectionHealthCheckWritesReportTest.php, apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionTest.php, apps/platform/tests/Feature/Onboarding/OnboardingProviderConnectionPlatformDefaultTest.php, and apps/platform/tests/Feature/ManagedTenantOnboardingWizardTest.php +Task: T013 Add audit regressions in apps/platform/tests/Feature/Audit/ProviderConnectionConsentAuditTest.php, apps/platform/tests/Feature/Audit/ProviderConnectionConsentRevocationAuditTest.php, and apps/platform/tests/Feature/Audit/ProviderConnectionVerificationAuditTest.php +``` + +## Parallel Example: User Story 3 + +```bash +# Lock contradiction coverage and final residual guards before dropping legacy provider columns: +Task: T019 Add contradiction scenarios in apps/platform/tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php, apps/platform/tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php, and apps/platform/tests/Feature/System/Spec114/DirectoryTenantsTest.php +Task: T020 Add legacy-badge and residual-fallback regressions in apps/platform/tests/Feature/Guards/NoLegacyProviderConnectionStateFallbackTest.php, apps/platform/tests/Unit/Badges/BooleanEnabledBadgesTest.php, apps/platform/tests/Unit/Badges/ProviderConnectionBadgesTest.php, apps/platform/tests/Unit/Providers/ProviderConnectionBadgeMappingTest.php, and apps/platform/tests/Unit/Badges/BadgeCatalogTest.php +``` + +--- + +## Implementation Strategy + +### First Demonstrable Milestone (Reader Cutover) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3 plus `T014` from Phase 4 to finish the canonical reader cutover. +4. Validate canonical provider surfaces and runtime reader gates across `/admin`, tenant summaries, and `/system` before moving into runtime writer cutover. + +### Incremental Delivery + +1. Finish Setup and Foundational work. +2. Deliver the full reader cutover across US1 plus `T014` and validate the canonical provider-state language across surfaces and runtime gates. +3. Deliver the remaining US2 writer work and validate runtime writes and transport cleanup. +4. Deliver US3 and validate the final hard cut and legacy removal. +5. Finish Phase 6 verification and smoke checks. + +### Parallel Team Strategy + +1. One developer completes Phase 1 and Phase 2. +2. After Phase 2, one developer takes US1 while another takes US2. +3. Rejoin on US3 once surfaces and runtime behavior are both canonical. +4. Finish with shared formatting, focused Sail tests, and the residual sweep. + +--- + +## Notes + +- Every task follows the required checklist format: checkbox, task ID, optional parallel marker, required story label for story phases, and exact file paths. +- The task list preserves the plan’s hard-cut order: lifecycle foundation first, surface and runtime cutover next, then residual cleanup and schema removal last. +- The recommended MVP review slice is US1, but the feature is not shippable until US2 and US3 are complete because the spec forbids compatibility tails.