Spec 188: canonical provider connection state cleanup #219

Merged
ahmido merged 1 commits from 188-provider-connection-state-cleanup into dev 2026-04-10 11:22:57 +00:00
105 changed files with 2666 additions and 750 deletions

View File

@ -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) - 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) - 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) - 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -195,8 +197,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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 - 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
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -10,7 +10,6 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionClassificationResult; use App\Services\Providers\ProviderConnectionClassificationResult;
use App\Services\Providers\ProviderConnectionClassifier; use App\Services\Providers\ProviderConnectionClassifier;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderCredentialKind; use App\Support\Providers\ProviderCredentialKind;
use App\Support\Providers\ProviderCredentialSource; 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.'; protected $description = 'Classify legacy provider connections into platform, dedicated, or review-required outcomes.';
public function handle( public function handle(ProviderConnectionClassifier $classifier): int
ProviderConnectionClassifier $classifier, {
ProviderConnectionStateProjector $stateProjector,
): int {
$query = $this->query(); $query = $this->query();
$write = (bool) $this->option('write'); $write = (bool) $this->option('write');
$chunkSize = max(1, (int) $this->option('chunk')); $chunkSize = max(1, (int) $this->option('chunk'));
@ -62,7 +59,6 @@ public function handle(
->orderBy('id') ->orderBy('id')
->chunkById($chunkSize, function ($connections) use ( ->chunkById($chunkSize, function ($connections) use (
$classifier, $classifier,
$stateProjector,
$write, $write,
$tenantCounts, $tenantCounts,
&$startedTenants, &$startedTenants,
@ -101,7 +97,7 @@ public function handle(
$startedTenants[$tenantKey] = true; $startedTenants[$tenantKey] = true;
} }
$connection = $this->applyClassification($connection, $result, $stateProjector); $connection = $this->applyClassification($connection, $result);
$this->auditApplied($tenant, $connection, $result); $this->auditApplied($tenant, $connection, $result);
$appliedCount++; $appliedCount++;
} }
@ -146,11 +142,10 @@ private function query(): Builder
private function applyClassification( private function applyClassification(
ProviderConnection $connection, ProviderConnection $connection,
ProviderConnectionClassificationResult $result, ProviderConnectionClassificationResult $result,
ProviderConnectionStateProjector $stateProjector,
): ProviderConnection { ): ProviderConnection {
DB::transaction(function () use ($connection, $result, $stateProjector): void { DB::transaction(function () use ($connection, $result): void {
$connection->forceFill( $connection->forceFill(
$connection->classificationProjection($result, $stateProjector) $connection->classificationProjection($result)
)->save(); )->save();
$credential = $connection->credential; $credential = $connection->credential;

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingLifecycleService; use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService; use App\Services\Providers\ProviderConnectionMutationService;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Services\Providers\ProviderOperationRegistry; use App\Services\Providers\ProviderOperationRegistry;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Tenants\TenantOperabilityService; use App\Services\Tenants\TenantOperabilityService;
@ -2535,12 +2534,6 @@ public function createProviderConnection(array $data): void
/** @var ProviderConnection $connection */ /** @var ProviderConnection $connection */
$connection = DB::transaction(function () use ($tenant, $displayName, $clientId, $clientSecret, $makeDefault, $usesDedicatedCredential, &$wasExistingConnection, &$previousConnectionType): ProviderConnection { $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() $connection = ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('provider', 'microsoft') ->where('provider', 'microsoft')
@ -2554,15 +2547,14 @@ public function createProviderConnection(array $data): void
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => $displayName, 'display_name' => $displayName,
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,

View File

@ -472,6 +472,11 @@ private static function consentStatusLabelFromState(mixed $state): string
return BadgeRenderer::spec(BadgeDomain::ProviderConsentStatus, $state)->label; 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 private static function verificationStatusLabelFromState(mixed $state): string
{ {
return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label; return BadgeRenderer::spec(BadgeDomain::ProviderVerificationStatus, $state)->label;
@ -512,6 +517,9 @@ public static function form(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Current state') Section::make('Current state')
->schema([ ->schema([
Placeholder::make('is_enabled_display')
->label('Lifecycle')
->content(fn (?ProviderConnection $record): string => static::lifecycleLabelFromState($record?->is_enabled)),
Placeholder::make('consent_status_display') Placeholder::make('consent_status_display')
->label('Consent') ->label('Consent')
->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)), ->content(fn (?ProviderConnection $record): string => static::consentStatusLabelFromState($record?->consent_status)),
@ -526,12 +534,6 @@ public static function form(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
Section::make('Diagnostics') Section::make('Diagnostics')
->schema([ ->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') Placeholder::make('migration_review_status_display')
->label('Migration review') ->label('Migration review')
->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record)) ->content(fn (?ProviderConnection $record): string => static::migrationReviewLabel($record))
@ -578,6 +580,13 @@ public static function infolist(Schema $schema): Schema
->columns(2), ->columns(2),
Section::make('Current state') Section::make('Current state')
->schema([ ->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') Infolists\Components\TextEntry::make('consent_status')
->label('Consent') ->label('Consent')
->badge() ->badge()
@ -599,20 +608,6 @@ public static function infolist(Schema $schema): Schema
->columns(2), ->columns(2),
Section::make('Diagnostics') Section::make('Diagnostics')
->schema([ ->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') Infolists\Components\TextEntry::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record)) ->formatStateUsing(fn (ProviderConnection $record): string => static::migrationReviewLabel($record))
@ -684,6 +679,13 @@ public static function table(Table $table): Table
? 'Dedicated' ? 'Dedicated'
: 'Platform') : 'Platform')
->color(fn (?ProviderConnectionType $state): string => $state === ProviderConnectionType::Dedicated ? 'info' : 'gray'), ->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') Tables\Columns\TextColumn::make('consent_status')
->label('Consent') ->label('Consent')
->badge() ->badge()
@ -698,22 +700,6 @@ public static function table(Table $table): Table
->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus))
->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus))
->iconColor(BadgeRenderer::iconColor(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') Tables\Columns\TextColumn::make('migration_review_required')
->label('Migration review') ->label('Migration review')
->badge() ->badge()
@ -796,12 +782,10 @@ public static function table(Table $table): Table
return $query->where('provider_connections.verification_status', $value); return $query->where('provider_connections.verification_status', $value);
}), }),
SelectFilter::make('status') SelectFilter::make('is_enabled')
->label('Diagnostic status') ->label('Lifecycle')
->options([ ->options([
'connected' => 'Connected', 'enabled' => 'Enabled',
'needs_consent' => 'Needs consent',
'error' => 'Error',
'disabled' => 'Disabled', 'disabled' => 'Disabled',
]) ])
->query(function (Builder $query, array $data): Builder { ->query(function (Builder $query, array $data): Builder {
@ -811,24 +795,7 @@ public static function table(Table $table): Table
return $query; return $query;
} }
return $query->where('provider_connections.status', $value); return $query->where('provider_connections.is_enabled', $value === 'enabled');
}),
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);
}), }),
Filter::make('default_only') Filter::make('default_only')
->label('Default only') ->label('Default only')
@ -847,7 +814,7 @@ public static function table(Table $table): Table
->label('Check connection') ->label('Check connection')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->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 { ->action(function (ProviderConnection $record, StartVerification $verification, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -946,7 +913,7 @@ public static function table(Table $table): Table
->label('Inventory sync') ->label('Inventory sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('info') ->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 { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -1043,7 +1010,7 @@ public static function table(Table $table): Table
->label('Compliance snapshot') ->label('Compliance snapshot')
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
->color('info') ->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 { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate, \Filament\Tables\Contracts\HasTable $livewire): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
$user = auth()->user(); $user = auth()->user();
@ -1141,7 +1108,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->color('primary') ->color('primary')
->requiresConfirmation() ->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 { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
@ -1383,7 +1350,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->requiresConfirmation() ->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 { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
@ -1392,15 +1359,14 @@ public static function table(Table $table): Table
} }
$hadCredentials = $record->credential()->exists(); $hadCredentials = $record->credential()->exists();
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$verificationStatus = $hadCredentials ? ProviderVerificationStatus::Unknown : ProviderVerificationStatus::Blocked;
$status = $hadCredentials ? 'connected' : 'error';
$errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing; $errorReasonCode = $hadCredentials ? null : ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.'; $errorMessage = $hadCredentials ? null : 'Provider connection credentials are missing.';
$record->update([ $record->update([
'status' => $status, 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => $errorReasonCode, 'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage, 'last_error_message' => $errorMessage,
@ -1418,8 +1384,9 @@ public static function table(Table $table): Table
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_status' => $status, 'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials, 'credentials_present' => $hadCredentials,
], ],
], ],
@ -1457,7 +1424,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation() ->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 { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = static::resolveTenantForRecord($record); $tenant = static::resolveTenantForRecord($record);
@ -1465,10 +1432,10 @@ public static function table(Table $table): Table
return; return;
} }
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$record->update([ $record->update([
'status' => 'disabled', 'is_enabled' => false,
]); ]);
$user = auth()->user(); $user = auth()->user();
@ -1483,7 +1450,8 @@ public static function table(Table $table): Table
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
], ],
], ],
actorId: $actorId, actorId: $actorId,

View File

@ -6,7 +6,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -30,27 +29,20 @@ protected function mutateFormDataBeforeCreate(array $data): array
$this->shouldMakeDefault = (bool) ($data['is_default'] ?? false); $this->shouldMakeDefault = (bool) ($data['is_default'] ?? false);
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
return [ return [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $data['entra_tenant_id'], 'entra_tenant_id' => $data['entra_tenant_id'],
'display_name' => $data['display_name'], 'display_name' => $data['display_name'],
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,

View File

@ -212,7 +212,7 @@ protected function getHeaderActions(): array
return $tenant instanceof Tenant return $tenant instanceof Tenant
&& $user instanceof User && $user instanceof User
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && (bool) $record->is_enabled;
}) })
->action(function (ProviderConnection $record, StartVerification $verification): void { ->action(function (ProviderConnection $record, StartVerification $verification): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -521,7 +521,7 @@ protected function getHeaderActions(): array
->color('primary') ->color('primary')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& $record->status !== 'disabled' && (bool) $record->is_enabled
&& ! $record->is_default && ! $record->is_default
&& ProviderConnection::query() && ProviderConnection::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
@ -581,7 +581,7 @@ protected function getHeaderActions(): array
return $tenant instanceof Tenant return $tenant instanceof Tenant
&& $user instanceof User && $user instanceof User
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && (bool) $record->is_enabled;
}) })
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -695,7 +695,7 @@ protected function getHeaderActions(): array
return $tenant instanceof Tenant return $tenant instanceof Tenant
&& $user instanceof User && $user instanceof User
&& $user->canAccessTenant($tenant) && $user->canAccessTenant($tenant)
&& $record->status !== 'disabled'; && (bool) $record->is_enabled;
}) })
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -803,7 +803,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->requiresConfirmation() ->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 { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -812,15 +812,19 @@ protected function getHeaderActions(): array
} }
$hadCredentials = $record->credential()->exists(); $hadCredentials = $record->credential()->exists();
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$verificationStatus = $hadCredentials ? \App\Support\Providers\ProviderVerificationStatus::Unknown : \App\Support\Providers\ProviderVerificationStatus::Blocked;
$status = $hadCredentials ? 'connected' : 'needs_consent';
$errorReasonCode = null; $errorReasonCode = null;
$errorMessage = null; $errorMessage = null;
if (! $hadCredentials) {
$errorReasonCode = \App\Support\Providers\ProviderReasonCodes::ProviderCredentialMissing;
$errorMessage = 'Provider connection credentials are missing.';
}
$record->update([ $record->update([
'status' => $status, 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => $errorReasonCode, 'last_error_reason_code' => $errorReasonCode,
'last_error_message' => $errorMessage, 'last_error_message' => $errorMessage,
@ -838,8 +842,9 @@ protected function getHeaderActions(): array
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_status' => $status, 'to_lifecycle' => 'enabled',
'verification_status' => $verificationStatus->value,
'credentials_present' => $hadCredentials, 'credentials_present' => $hadCredentials,
], ],
], ],
@ -853,8 +858,8 @@ protected function getHeaderActions(): array
if (! $hadCredentials) { if (! $hadCredentials) {
Notification::make() Notification::make()
->title('Connection enabled (needs consent)') ->title('Connection enabled (credentials missing)')
->body('Grant admin consent before running checks or operations.') ->body('Add credentials before running checks or operations.')
->warning() ->warning()
->send(); ->send();
@ -878,7 +883,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation() ->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 { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
@ -886,10 +891,10 @@ protected function getHeaderActions(): array
return; return;
} }
$previousStatus = (string) $record->status; $previousLifecycle = (bool) $record->is_enabled;
$record->update([ $record->update([
'status' => 'disabled', 'is_enabled' => false,
]); ]);
$user = auth()->user(); $user = auth()->user();
@ -904,7 +909,8 @@ protected function getHeaderActions(): array
'metadata' => [ 'metadata' => [
'provider' => $record->provider, 'provider' => $record->provider,
'entra_tenant_id' => $record->entra_tenant_id, 'entra_tenant_id' => $record->entra_tenant_id,
'from_status' => $previousStatus, 'from_lifecycle' => $previousLifecycle ? 'enabled' : 'disabled',
'to_lifecycle' => 'disabled',
], ],
], ],
actorId: $actorId, actorId: $actorId,

View File

@ -2084,10 +2084,12 @@ public static function adminConsentUrl(Tenant $tenant): ?string
* @return array{ * @return array{
* state:string, * state:string,
* cta_url:string, * cta_url:string,
* lifecycle:?string,
* is_enabled:?bool,
* display_name:?string, * display_name:?string,
* provider:?string, * provider:?string,
* status:?string, * consent_status:?string,
* health_status:?string, * verification_status:?string,
* last_health_check_at:?string, * last_health_check_at:?string,
* last_error_reason_code:?string * last_error_reason_code:?string
* } * }
@ -2118,10 +2120,10 @@ private static function providerConnectionState(Tenant $tenant): array
'needs_default_connection' => false, 'needs_default_connection' => false,
'display_name' => null, 'display_name' => null,
'provider' => null, 'provider' => null,
'lifecycle' => null,
'is_enabled' => null,
'consent_status' => null, 'consent_status' => null,
'verification_status' => null, 'verification_status' => null,
'status' => null,
'health_status' => null,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => null, 'last_error_reason_code' => null,
]; ];
@ -2133,14 +2135,14 @@ private static function providerConnectionState(Tenant $tenant): array
'needs_default_connection' => ! $connection->is_default, 'needs_default_connection' => ! $connection->is_default,
'display_name' => (string) $connection->display_name, 'display_name' => (string) $connection->display_name,
'provider' => (string) $connection->provider, 'provider' => (string) $connection->provider,
'lifecycle' => (bool) $connection->is_enabled ? 'enabled' : 'disabled',
'is_enabled' => (bool) $connection->is_enabled,
'consent_status' => $connection->consent_status instanceof BackedEnum 'consent_status' => $connection->consent_status instanceof BackedEnum
? (string) $connection->consent_status->value ? (string) $connection->consent_status->value
: (is_string($connection->consent_status) ? $connection->consent_status : null), : (is_string($connection->consent_status) ? $connection->consent_status : null),
'verification_status' => $connection->verification_status instanceof BackedEnum 'verification_status' => $connection->verification_status instanceof BackedEnum
? (string) $connection->verification_status->value ? (string) $connection->verification_status->value
: (is_string($connection->verification_status) ? $connection->verification_status : null), : (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_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, 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
]; ];

View File

@ -68,8 +68,14 @@ public function table(Table $table): Table
return Tenant::query() return Tenant::query()
->with('workspace') ->with('workspace')
->withCount([ ->withCount([
'providerConnections', 'providerConnections as critical_provider_connections_count' => fn (Builder $query): Builder => $query
'providerConnections as unhealthy_connections_count' => fn (Builder $query): Builder => $query->where('health_status', 'unhealthy'), ->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'), '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'; return 'unknown';
} }
if ((int) ($tenant->getAttribute('unhealthy_connections_count') ?? 0) > 0) { if ((int) ($tenant->getAttribute('critical_provider_connections_count') ?? 0) > 0) {
return 'critical'; 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'; return 'warn';
} }

View File

@ -50,7 +50,17 @@ public function providerConnections(): Collection
->where('tenant_id', (int) $this->tenant->getKey()) ->where('tenant_id', (int) $this->tenant->getKey())
->orderByDesc('is_default') ->orderByDesc('is_default')
->orderBy('provider') ->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',
]);
} }
/** /**

View File

@ -5,7 +5,6 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -144,11 +143,6 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
default => ProviderConsentStatus::Required, default => ProviderConsentStatus::Required,
}; };
$verificationStatus = ProviderVerificationStatus::Unknown; $verificationStatus = ProviderVerificationStatus::Unknown;
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$reasonCode = match ($status) { $reasonCode = match ($status) {
'ok' => null, 'ok' => null,
'error' => ProviderReasonCodes::ProviderAuthFailed, 'error' => ProviderReasonCodes::ProviderAuthFailed,
@ -164,19 +158,18 @@ private function upsertProviderConnectionForConsent(Tenant $tenant, string $stat
[ [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => $consentStatus->value, 'consent_status' => $consentStatus->value,
'consent_granted_at' => $status === 'ok' ? now() : null, 'consent_granted_at' => $status === 'ok' ? now() : null,
'consent_last_checked_at' => now(), 'consent_last_checked_at' => now(),
'consent_error_code' => $reasonCode, 'consent_error_code' => $status === 'error' ? $reasonCode : null,
'consent_error_message' => $error, 'consent_error_message' => $status === 'error' ? $error : null,
'verification_status' => $verificationStatus->value, 'verification_status' => $verificationStatus->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => $reasonCode, 'last_error_reason_code' => $reasonCode,
'last_error_message' => $error, 'last_error_message' => $status === 'ok' ? null : $error,
'is_default' => $hasDefault ? false : true, 'is_default' => $hasDefault ? false : true,
], ],
); );

View File

@ -6,7 +6,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\AdminConsentUrlFactory; use App\Services\Providers\AdminConsentUrlFactory;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Support\Providers\ProviderConnectionType; use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -100,12 +99,6 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
->where('is_default', true) ->where('is_default', true)
->exists(); ->exists();
$projectedState = app(ProviderConnectionStateProjector::class)->project(
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
$connection = ProviderConnection::query()->updateOrCreate( $connection = ProviderConnection::query()->updateOrCreate(
[ [
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
@ -115,15 +108,14 @@ private function upsertPlatformConnection(Tenant $tenant): ProviderConnection
[ [
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'), 'display_name' => (string) ($tenant->name ?? 'Microsoft Connection'),
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => $projectedState['status'],
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => $projectedState['health_status'],
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,

View File

@ -385,8 +385,6 @@ private function applyHealthResult(ProviderConnection $connection, HealthResult
$connection->update([ $connection->update([
'consent_status' => $projected['consent_status'], 'consent_status' => $projected['consent_status'],
'verification_status' => $projected['verification_status'], 'verification_status' => $projected['verification_status'],
'status' => $projected['status'],
'health_status' => $projected['health_status'],
'last_health_check_at' => now(), 'last_health_check_at' => now(),
'last_error_reason_code' => $projected['last_error_reason_code'], 'last_error_reason_code' => $projected['last_error_reason_code'],
'last_error_message' => $projected['last_error_message'], 'last_error_message' => $projected['last_error_message'],
@ -449,12 +447,11 @@ private function logVerificationResult(
'metadata' => [ 'metadata' => [
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
'connection_type' => $connection->connection_type?->value ?? $connection->connection_type, 'connection_type' => $connection->connection_type?->value ?? $connection->connection_type,
'is_enabled' => (bool) $connection->is_enabled,
'consent_status' => $connection->consent_status?->value ?? $connection->consent_status, 'consent_status' => $connection->consent_status?->value ?? $connection->consent_status,
'verification_status' => $connection->verification_status?->value ?? $connection->verification_status, 'verification_status' => $connection->verification_status?->value ?? $connection->verification_status,
'credential_source' => $identity->credentialSource, 'credential_source' => $identity->credentialSource,
'effective_client_id' => $identity->effectiveClientId, 'effective_client_id' => $identity->effectiveClientId,
'status' => $connection->status,
'health_status' => $connection->health_status,
'reason_code' => $reasonCode, 'reason_code' => $reasonCode,
'operation_run_id' => (int) $run->getKey(), 'operation_run_id' => (int) $run->getKey(),
'previous_consent_status' => $previousConsentStatus, 'previous_consent_status' => $previousConsentStatus,

View File

@ -13,6 +13,7 @@
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderConsentStatus;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -44,7 +45,10 @@ public function handle(
// FR-018: Skip tenants without active provider connection // FR-018: Skip tenants without active provider connection
$hasConnection = ProviderConnection::query() $hasConnection = ProviderConnection::query()
->where('tenant_id', $tenant->getKey()) ->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(); ->exists();
if (! $hasConnection) { if (! $hasConnection) {

View File

@ -21,6 +21,7 @@ class ProviderConnection extends Model
protected $casts = [ protected $casts = [
'is_default' => 'boolean', 'is_default' => 'boolean',
'is_enabled' => 'boolean',
'connection_type' => ProviderConnectionType::class, 'connection_type' => ProviderConnectionType::class,
'consent_status' => ProviderConsentStatus::class, 'consent_status' => ProviderConsentStatus::class,
'consent_granted_at' => 'datetime', 'consent_granted_at' => 'datetime',
@ -151,7 +152,6 @@ public function requiresMigrationReview(): bool
*/ */
public function classificationProjection( public function classificationProjection(
\App\Services\Providers\ProviderConnectionClassificationResult $result, \App\Services\Providers\ProviderConnectionClassificationResult $result,
\App\Services\Providers\ProviderConnectionStateProjector $stateProjector,
): array { ): array {
$metadata = array_merge( $metadata = array_merge(
is_array($this->metadata) ? $this->metadata : [], is_array($this->metadata) ? $this->metadata : [],
@ -166,17 +166,8 @@ public function classificationProjection(
]; ];
if ($result->reviewRequired) { 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 + [ return $projection + [
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => $statusProjection['status'],
'health_status' => $statusProjection['health_status'],
'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired, 'last_error_reason_code' => ProviderReasonCodes::ProviderConnectionReviewRequired,
'last_error_message' => 'Legacy provider connection requires explicit migration review.', 'last_error_message' => 'Legacy provider connection requires explicit migration review.',
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
@ -197,19 +188,10 @@ public function classificationProjection(
$this->migration_review_required $this->migration_review_required
|| $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired || $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 + [ return $projection + [
'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus 'verification_status' => $currentVerificationStatus instanceof ProviderVerificationStatus
? $currentVerificationStatus->value ? $currentVerificationStatus->value
: $currentVerificationStatus, : $currentVerificationStatus,
'status' => $statusProjection['status'],
'health_status' => $statusProjection['health_status'],
'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired 'last_error_reason_code' => $currentReasonCode === ProviderReasonCodes::ProviderConnectionReviewRequired
? null ? null
: $currentReasonCode, : $currentReasonCode,

View File

@ -2,6 +2,8 @@
namespace App\Services\Providers\Contracts; namespace App\Services\Providers\Contracts;
use App\Support\Providers\ProviderVerificationStatus;
final class HealthResult final class HealthResult
{ {
/** /**
@ -9,8 +11,7 @@ final class HealthResult
*/ */
public function __construct( public function __construct(
public readonly bool $healthy, public readonly bool $healthy,
public readonly string $status, public readonly string $verificationStatus,
public readonly string $healthStatus,
public readonly ?string $reasonCode = null, public readonly ?string $reasonCode = null,
public readonly ?string $message = null, public readonly ?string $message = null,
public readonly array $meta = [], public readonly array $meta = [],
@ -19,9 +20,9 @@ public function __construct(
/** /**
* @param array<string, mixed> $meta * @param array<string, mixed> $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( public static function failed(
string $reasonCode, string $reasonCode,
string $message, string $message,
string $status = 'error', string $verificationStatus = 'error',
string $healthStatus = 'down',
array $meta = [], array $meta = [],
): self { ): self {
return new self(false, $status, $healthStatus, $reasonCode, $message, $meta); return new self(false, $verificationStatus, $reasonCode, $message, $meta);
} }
} }

View File

@ -8,6 +8,7 @@
use App\Services\Providers\Contracts\ProviderHealthCheck; use App\Services\Providers\Contracts\ProviderHealthCheck;
use App\Support\OpsUx\RunFailureSanitizer; use App\Support\OpsUx\RunFailureSanitizer;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Throwable; use Throwable;
final class MicrosoftProviderHealthCheck implements ProviderHealthCheck final class MicrosoftProviderHealthCheck implements ProviderHealthCheck
@ -25,15 +26,12 @@ public function check(ProviderConnection $connection): HealthResult
return HealthResult::failed( return HealthResult::failed(
reasonCode: $reasonCode, reasonCode: $reasonCode,
message: $message !== '' ? $message : 'Health check failed.', message: $message !== '' ? $message : 'Health check failed.',
status: $this->statusForReason($reasonCode), verificationStatus: $this->verificationStatusForReason($reasonCode),
healthStatus: $this->healthForReason($reasonCode),
); );
} }
if ($response->successful()) { if ($response->successful()) {
return HealthResult::ok( return HealthResult::ok(
status: 'connected',
healthStatus: 'ok',
meta: [ meta: [
'organization_id' => $response->data['id'] ?? null, 'organization_id' => $response->data['id'] ?? null,
'organization_display_name' => $response->data['displayName'] ?? null, 'organization_display_name' => $response->data['displayName'] ?? null,
@ -47,8 +45,7 @@ public function check(ProviderConnection $connection): HealthResult
return HealthResult::failed( return HealthResult::failed(
reasonCode: $reasonCode, reasonCode: $reasonCode,
message: $message !== '' ? $message : 'Health check failed.', message: $message !== '' ? $message : 'Health check failed.',
status: $this->statusForReason($reasonCode), verificationStatus: $this->verificationStatusForReason($reasonCode),
healthStatus: $this->healthForReason($reasonCode),
meta: [ meta: [
'http_status' => $response->status, 'http_status' => $response->status,
], ],
@ -89,24 +86,14 @@ private function messageForResponse(GraphResponse $response): string
return 'Health check failed.'; return 'Health check failed.';
} }
private function statusForReason(string $reasonCode): string private function verificationStatusForReason(string $reasonCode): string
{ {
return match ($reasonCode) { return match ($reasonCode) {
ProviderReasonCodes::ProviderAuthFailed, ProviderReasonCodes::RateLimited => ProviderVerificationStatus::Degraded->value,
ProviderReasonCodes::ProviderPermissionDenied, ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentMissing => 'needs_consent', ProviderReasonCodes::ProviderConsentFailed,
default => 'error', ProviderReasonCodes::ProviderConsentRevoked => ProviderVerificationStatus::Blocked->value,
}; default => ProviderVerificationStatus::Error->value,
}
private function healthForReason(string $reasonCode): string
{
return match ($reasonCode) {
ProviderReasonCodes::RateLimited => 'degraded',
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderPermissionDenied => 'down',
default => 'down',
}; };
} }
} }

View File

@ -50,9 +50,9 @@ public function classify(
'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null, 'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null,
'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false), 'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false),
'current_connection_type' => $currentConnectionType, 'current_connection_type' => $currentConnectionType,
'is_enabled' => (bool) $connection->is_enabled,
'consent_status' => $this->enumValue($connection->consent_status), 'consent_status' => $this->enumValue($connection->consent_status),
'verification_status' => $this->enumValue($connection->verification_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, 'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
], ],
effectiveApp: $this->effectiveAppMetadata( effectiveApp: $this->effectiveAppMetadata(

View File

@ -15,10 +15,7 @@
final class ProviderConnectionMutationService final class ProviderConnectionMutationService
{ {
public function __construct( public function __construct(private readonly CredentialManager $credentials) {}
private readonly CredentialManager $credentials,
private readonly ProviderConnectionStateProjector $stateProjector,
) {}
public function enableDedicatedOverride( public function enableDedicatedOverride(
ProviderConnection $connection, ProviderConnection $connection,
@ -50,15 +47,10 @@ public function enableDedicatedOverride(
: $this->normalizeConsentStatus($connection->consent_status); : $this->normalizeConsentStatus($connection->consent_status);
$verificationStatus = ProviderVerificationStatus::Unknown; $verificationStatus = ProviderVerificationStatus::Unknown;
$updates = $this->projectConnectionState( $connection->forceFill([
connection: $connection,
connectionType: ProviderConnectionType::Dedicated,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$connection->forceFill(array_merge($updates, [
'connection_type' => ProviderConnectionType::Dedicated->value, 'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => $needsConsentReset 'last_error_reason_code' => $needsConsentReset
? ProviderReasonCodes::ProviderConsentMissing ? ProviderReasonCodes::ProviderConsentMissing
@ -67,9 +59,9 @@ public function enableDedicatedOverride(
'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []), 'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []),
'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at, 'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at,
'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at, 'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at,
'consent_error_code' => $needsConsentReset ? null : $connection->consent_error_code, 'consent_error_code' => null,
'consent_error_message' => $needsConsentReset ? null : $connection->consent_error_message, 'consent_error_message' => null,
]))->save(); ])->save();
$this->credentials->upsertClientSecretCredential( $this->credentials->upsertClientSecretCredential(
connection: $connection->fresh(), connection: $connection->fresh(),
@ -90,15 +82,9 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec
$connection->credential->delete(); $connection->credential->delete();
} }
$updates = $this->projectConnectionState( $connection->forceFill([
connection: $connection,
connectionType: ProviderConnectionType::Platform,
consentStatus: ProviderConsentStatus::Required,
verificationStatus: ProviderVerificationStatus::Unknown,
);
$connection->forceFill(array_merge($updates, [
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
@ -108,7 +94,7 @@ public function revertToPlatform(ProviderConnection $connection): ProviderConnec
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing, 'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'last_error_message' => null, 'last_error_message' => null,
'scopes_granted' => [], 'scopes_granted' => [],
]))->save(); ])->save();
return $connection->fresh(['credential']); return $connection->fresh(['credential']);
}); });
@ -126,47 +112,19 @@ public function deleteDedicatedCredential(ProviderConnection $connection): Provi
$consentStatus = $this->normalizeConsentStatus($connection->consent_status); $consentStatus = $this->normalizeConsentStatus($connection->consent_status);
$verificationStatus = ProviderVerificationStatus::Blocked; $verificationStatus = ProviderVerificationStatus::Blocked;
$updates = $this->projectConnectionState( $connection->forceFill([
connection: $connection,
connectionType: ProviderConnectionType::Dedicated,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
);
$connection->forceFill(array_merge($updates, [
'connection_type' => ProviderConnectionType::Dedicated->value, 'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => $consentStatus->value, 'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value, 'verification_status' => $verificationStatus->value,
'last_health_check_at' => null, 'last_health_check_at' => null,
'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing, 'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing,
'last_error_message' => 'Dedicated credential is missing.', 'last_error_message' => 'Dedicated credential is missing.',
]))->save(); ])->save();
return $connection->fresh(['credential']); 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( private function normalizeConsentStatus(
ProviderConsentStatus|string|null $consentStatus, ProviderConsentStatus|string|null $consentStatus,
): ProviderConsentStatus { ): ProviderConsentStatus {

View File

@ -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( return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConnectionInvalid, ProviderReasonCodes::ProviderConnectionInvalid,
'Provider connection is disabled.', 'Provider connection is disabled.',
@ -95,7 +95,10 @@ private function consentBlocker(ProviderConnection $connection): ?ProviderConnec
{ {
$consentStatus = $connection->consent_status; $consentStatus = $connection->consent_status;
if ($consentStatus instanceof ProviderConsentStatus) { if (! $consentStatus instanceof ProviderConsentStatus && is_string($consentStatus)) {
$consentStatus = ProviderConsentStatus::tryFrom(trim($consentStatus));
}
return match ($consentStatus) { return match ($consentStatus) {
ProviderConsentStatus::Required => ProviderConnectionResolution::blocked( ProviderConsentStatus::Required => ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentMissing, ProviderReasonCodes::ProviderConsentMissing,
@ -118,16 +121,4 @@ private function consentBlocker(ProviderConnection $connection): ?ProviderConnec
default => null, default => null,
}; };
} }
if ((string) $connection->status === 'needs_consent') {
return ProviderConnectionResolution::blocked(
ProviderReasonCodes::ProviderConsentMissing,
'Provider connection requires admin consent before use.',
'ext.connection_needs_consent',
$connection,
);
}
return null;
}
} }

View File

@ -4,55 +4,16 @@
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Services\Providers\Contracts\HealthResult; use App\Services\Providers\Contracts\HealthResult;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus; use App\Support\Providers\ProviderVerificationStatus;
final class ProviderConnectionStateProjector 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{ * @return array{
* consent_status: ProviderConsentStatus, * consent_status: ProviderConsentStatus,
* verification_status: ProviderVerificationStatus, * verification_status: ProviderVerificationStatus,
* status: string,
* health_status: string,
* last_error_reason_code: ?string, * last_error_reason_code: ?string,
* last_error_message: ?string, * last_error_message: ?string,
* consent_error_code: ?string, * consent_error_code: ?string,
@ -62,22 +23,14 @@ public function project(
*/ */
public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array public function projectVerificationOutcome(ProviderConnection $connection, HealthResult $result): array
{ {
$currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) $currentConsentStatus = $this->normalizeConsentStatus($connection->consent_status) ?? ProviderConsentStatus::Unknown;
?? (((string) $connection->status === 'needs_consent') ? ProviderConsentStatus::Required : ProviderConsentStatus::Unknown);
$effectiveReasonCode = $result->healthy $effectiveReasonCode = $result->healthy
? null ? null
: $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode); : $this->effectiveReasonCodeForVerification($currentConsentStatus, $result->reasonCode);
$consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy); $consentStatus = $this->consentStatusAfterVerification($currentConsentStatus, $effectiveReasonCode, $result->healthy);
$verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result->healthy, $result->healthStatus); $verificationStatus = $this->verificationStatusAfterVerification($effectiveReasonCode, $result);
$projected = $this->project(
connectionType: $connection->connection_type,
consentStatus: $consentStatus,
verificationStatus: $verificationStatus,
currentStatus: is_string($connection->status) ? $connection->status : null,
);
$consentErrorCode = in_array($consentStatus, [ $consentErrorCode = in_array($consentStatus, [
ProviderConsentStatus::Required, ProviderConsentStatus::Required,
@ -88,8 +41,6 @@ public function projectVerificationOutcome(ProviderConnection $connection, Healt
return [ return [
'consent_status' => $consentStatus, 'consent_status' => $consentStatus,
'verification_status' => $verificationStatus, 'verification_status' => $verificationStatus,
'status' => $projected['status'],
'health_status' => $projected['health_status'],
'last_error_reason_code' => $effectiveReasonCode, 'last_error_reason_code' => $effectiveReasonCode,
'last_error_message' => $result->healthy ? null : $result->message, 'last_error_message' => $result->healthy ? null : $result->message,
'consent_error_code' => $consentErrorCode, '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 private function normalizeConsentStatus(ProviderConsentStatus|string|null $consentStatus): ?ProviderConsentStatus
{ {
if ($consentStatus instanceof ProviderConsentStatus) { if ($consentStatus instanceof ProviderConsentStatus) {
@ -139,41 +77,6 @@ private function normalizeVerificationStatus(
return ProviderVerificationStatus::tryFrom(trim($verificationStatus)); 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( private function effectiveReasonCodeForVerification(
ProviderConsentStatus $currentConsentStatus, ProviderConsentStatus $currentConsentStatus,
?string $reasonCode, ?string $reasonCode,
@ -211,17 +114,12 @@ private function consentStatusAfterVerification(
private function verificationStatusAfterVerification( private function verificationStatusAfterVerification(
?string $reasonCode, ?string $reasonCode,
bool $healthy, HealthResult $result,
string $healthStatus,
): ProviderVerificationStatus { ): ProviderVerificationStatus {
if ($healthy) { if ($result->healthy) {
return ProviderVerificationStatus::Healthy; return ProviderVerificationStatus::Healthy;
} }
if ($healthStatus === 'degraded' || $reasonCode === ProviderReasonCodes::RateLimited) {
return ProviderVerificationStatus::Degraded;
}
if (in_array($reasonCode, [ if (in_array($reasonCode, [
ProviderReasonCodes::ProviderConsentMissing, ProviderReasonCodes::ProviderConsentMissing,
ProviderReasonCodes::ProviderConsentFailed, ProviderReasonCodes::ProviderConsentFailed,
@ -238,6 +136,6 @@ private function verificationStatusAfterVerification(
return ProviderVerificationStatus::Blocked; return ProviderVerificationStatus::Blocked;
} }
return ProviderVerificationStatus::Error; return $this->normalizeVerificationStatus($result->verificationStatus) ?? ProviderVerificationStatus::Error;
} }
} }

View File

@ -10,7 +10,6 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderConnectionResolver;
use App\Services\Providers\ProviderConnectionStateProjector;
use App\Services\Providers\ProviderIdentityResolver; use App\Services\Providers\ProviderIdentityResolver;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Services\Providers\ProviderOperationStartResult; use App\Services\Providers\ProviderOperationStartResult;
@ -27,7 +26,6 @@ public function __construct(
private readonly ProviderOperationStartGate $providers, private readonly ProviderOperationStartGate $providers,
private readonly ProviderConnectionResolver $connections, private readonly ProviderConnectionResolver $connections,
private readonly ProviderIdentityResolver $identityResolver, private readonly ProviderIdentityResolver $identityResolver,
private readonly ProviderConnectionStateProjector $stateProjector,
) {} ) {}
/** /**
@ -126,17 +124,8 @@ public function providerConnectionCheckUsingConnection(
); );
if ($result->status === 'started') { 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([ $connection->update([
'verification_status' => ProviderVerificationStatus::Pending, 'verification_status' => ProviderVerificationStatus::Pending,
'status' => $projectedState['status'],
'health_status' => $projectedState['health_status'],
'last_error_reason_code' => null, 'last_error_reason_code' => null,
'last_error_message' => null, 'last_error_message' => null,
]); ]);

View File

@ -49,8 +49,6 @@ final class BadgeCatalog
BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class,
BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class, BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class,
BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::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::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class,
BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class, BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class,
BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class, BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class,
@ -157,18 +155,6 @@ public static function normalizeState(mixed $value): ?string
return $normalized === '' ? null : $normalized; 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 public static function normalizeProviderConsentStatus(mixed $value): ?string
{ {
$state = self::normalizeState($value); $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 public static function normalizeManagedTenantOnboardingVerificationStatus(mixed $value): ?string
{ {
$state = self::normalizeState($value); $state = self::normalizeState($value);

View File

@ -40,8 +40,6 @@ enum BadgeDomain: string
case RestoreResultStatus = 'restore_result_status'; case RestoreResultStatus = 'restore_result_status';
case ProviderConsentStatus = 'provider_connection.consent_status'; case ProviderConsentStatus = 'provider_connection.consent_status';
case ProviderVerificationStatus = 'provider_connection.verification_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 ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status';
case VerificationCheckStatus = 'verification_check_status'; case VerificationCheckStatus = 'verification_check_status';
case VerificationCheckSeverity = 'verification_check_severity'; case VerificationCheckSeverity = 'verification_check_severity';

View File

@ -1,23 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConnectionHealthBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderConnectionHealth($value);
return match ($state) {
'ok' => 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(),
};
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class ProviderConnectionStatusBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeProviderConnectionStatus($value);
return match ($state) {
'connected' => 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(),
};
}
}

View File

@ -30,11 +30,11 @@ public static function tenantsIndex(): string
return Tenants::getUrl(panel: 'system'); 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 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]); 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;
} }
} }

View File

@ -47,15 +47,14 @@ public function definition(): array
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'display_name' => fake()->company(), 'display_name' => fake()->company(),
'is_default' => false, 'is_default' => false,
'is_enabled' => true,
'connection_type' => ProviderConnectionType::Platform->value, 'connection_type' => ProviderConnectionType::Platform->value,
'status' => 'needs_consent',
'consent_status' => ProviderConsentStatus::Required->value, 'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null, 'consent_granted_at' => null,
'consent_last_checked_at' => null, 'consent_last_checked_at' => null,
'consent_error_code' => null, 'consent_error_code' => null,
'consent_error_message' => null, 'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'health_status' => 'unknown',
'migration_review_required' => false, 'migration_review_required' => false,
'migration_reviewed_at' => null, 'migration_reviewed_at' => null,
'scopes_granted' => [], 'scopes_granted' => [],
@ -83,7 +82,7 @@ public function dedicated(): static
public function consentGranted(): static public function consentGranted(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => 'connected', 'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'consent_granted_at' => now(), 'consent_granted_at' => now(),
'consent_last_checked_at' => now(), 'consent_last_checked_at' => now(),
@ -93,13 +92,19 @@ public function consentGranted(): static
public function verifiedHealthy(): static public function verifiedHealthy(): static
{ {
return $this->state(fn (): array => [ return $this->state(fn (): array => [
'status' => 'connected', 'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'consent_granted_at' => now(), 'consent_granted_at' => now(),
'consent_last_checked_at' => now(), 'consent_last_checked_at' => now(),
'verification_status' => ProviderVerificationStatus::Healthy->value, 'verification_status' => ProviderVerificationStatus::Healthy->value,
'health_status' => 'ok',
'last_health_check_at' => now(), 'last_health_check_at' => now(),
]); ]);
} }
public function disabled(): static
{
return $this->state(fn (): array => [
'is_enabled' => false,
]);
}
} }

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('provider_connections', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('provider_connections', function (Blueprint $table): void {
$table->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']);
});
}
};

View File

@ -8,18 +8,17 @@
$displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null; $displayName = is_string($state['display_name'] ?? null) ? (string) $state['display_name'] : null;
$provider = is_string($state['provider'] ?? null) ? (string) $state['provider'] : 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; $consentStatus = is_string($state['consent_status'] ?? null) ? (string) $state['consent_status'] : null;
$verificationStatus = is_string($state['verification_status'] ?? null) ? (string) $state['verification_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; $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; $lastErrorReason = is_string($state['last_error_reason_code'] ?? null) ? (string) $state['last_error_reason_code'] : null;
$isMissing = $connectionState === 'missing'; $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); $consentSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $consentStatus);
$verificationSpec = \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $verificationStatus); $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 @endphp
<div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm"> <div class="space-y-3 rounded-md border border-gray-200 bg-white p-4 shadow-sm">
@ -52,6 +51,14 @@
<dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Provider</dt>
<dd>{{ $provider ?? 'n/a' }}</dd> <dd>{{ $provider ?? 'n/a' }}</dd>
</div> </div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Lifecycle</dt>
<dd>
<x-filament::badge :color="$lifecycleSpec->color" :icon="$lifecycleSpec->icon" size="sm">
{{ $lifecycleSpec->label }}
</x-filament::badge>
</dd>
</div>
<div> <div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt> <dt class="text-xs uppercase tracking-wide text-gray-500">Consent</dt>
<dd> <dd>
@ -76,25 +83,6 @@
<div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700"> <div class="space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3 text-sm text-gray-700">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500">Diagnostics</div>
<dl class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy status</dt>
<dd>
<x-filament::badge :color="$legacyStatusSpec->color" :icon="$legacyStatusSpec->icon" size="sm">
{{ $legacyStatusSpec->label }}
</x-filament::badge>
</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-gray-500">Legacy health</dt>
<dd>
<x-filament::badge :color="$legacyHealthSpec->color" :icon="$legacyHealthSpec->icon" size="sm">
{{ $legacyHealthSpec->label }}
</x-filament::badge>
</dd>
</div>
</dl>
@if ($lastErrorReason) @if ($lastErrorReason)
<div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800"> <div class="rounded-md border border-amber-300 bg-amber-50 p-2 text-xs text-amber-800">
Last error reason: {{ $lastErrorReason }} Last error reason: {{ $lastErrorReason }}

View File

@ -51,22 +51,43 @@
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span> <span class="font-medium text-gray-950 dark:text-white">{{ $connection->provider }}</span>
@if ($connection->display_name)
<span class="text-xs text-gray-500 dark:text-gray-400">{{ $connection->display_name }}</span>
@endif
<x-filament::badge <x-filament::badge
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionStatus, (string) $connection->status)->color" :color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->color"
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::BooleanEnabled, $connection->is_enabled)->icon"
> >
{{ \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 }}
</x-filament::badge> </x-filament::badge>
<x-filament::badge <x-filament::badge
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConnectionHealth, (string) $connection->health_status)->color" :color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->color"
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderConsentStatus, $connection->consent_status)->icon"
> >
{{ \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 }}
</x-filament::badge>
<x-filament::badge
:color="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->color"
:icon="\App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->icon"
>
{{ \App\Support\Badges\BadgeRenderer::spec(\App\Support\Badges\BadgeDomain::ProviderVerificationStatus, $connection->verification_status)->label }}
</x-filament::badge> </x-filament::badge>
@if ($connection->is_default) @if ($connection->is_default)
<x-filament::badge color="info">Default</x-filament::badge> <x-filament::badge color="info">Default</x-filament::badge>
@endif @endif
</div> </div>
<div class="mt-2 flex flex-wrap gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>Last check: {{ $connection->last_health_check_at?->diffForHumans() ?? 'Never' }}</span>
@if ($connection->last_error_reason_code)
<span>Last error: {{ $connection->last_error_reason_code }}</span>
@endif
</div>
</div> </div>
@endforeach @endforeach
</div> </div>

View File

@ -39,7 +39,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser platform connection', 'display_name' => 'Browser platform connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$draft = createOnboardingDraft([ $draft = createOnboardingDraft([
@ -129,7 +129,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Polling verification connection', 'display_name' => 'Polling verification connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -233,7 +233,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Polling bootstrap connection', 'display_name' => 'Polling bootstrap connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$verificationRun = OperationRun::factory()->create([ $verificationRun = OperationRun::factory()->create([

View File

@ -40,7 +40,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Previously verified connection', 'display_name' => 'Previously verified connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([ $selectedConnection = ProviderConnection::factory()->platform()->consentGranted()->create([
@ -50,7 +50,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Current selected connection', 'display_name' => 'Current selected connection',
'is_default' => false, 'is_default' => false,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -142,7 +142,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Blocked review connection', 'display_name' => 'Blocked review connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$verificationRun = OperationRun::factory()->create([ $verificationRun = OperationRun::factory()->create([
@ -268,7 +268,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser assist connection', 'display_name' => 'Browser assist connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -405,7 +405,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Browser next-step connection', 'display_name' => 'Browser next-step connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -118,7 +118,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
TenantOnboardingSession::query()->create([ TenantOnboardingSession::query()->create([
@ -169,7 +169,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -238,7 +238,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Spec172 completed connection', 'display_name' => 'Spec172 completed connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$previousRun = OperationRun::factory()->create([ $previousRun = OperationRun::factory()->create([

View File

@ -8,7 +8,7 @@
uses(RefreshDatabase::class); 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 = Tenant::factory()->create([
'tenant_id' => 'tenant-1', 'tenant_id' => 'tenant-1',
'name' => 'Contoso', 'name' => 'Contoso',
@ -32,7 +32,9 @@
->first(); ->first();
expect($connection)->not->toBeNull() 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(); ->and($connection?->last_error_reason_code)->toBeNull();
$this->assertDatabaseHas('audit_logs', [ $this->assertDatabaseHas('audit_logs', [
@ -81,11 +83,13 @@
->first(); ->first();
expect($connection)->not->toBeNull() 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); ->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 = Tenant::factory()->create([
'tenant_id' => 'tenant-2', 'tenant_id' => 'tenant-2',
'name' => 'Fabrikam', 'name' => 'Fabrikam',
@ -105,7 +109,9 @@
->first(); ->first();
expect($connection)->not->toBeNull() 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_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed)
->and($connection?->last_error_message)->toBe('access_denied'); ->and($connection?->last_error_message)->toBe('access_denied');

View File

@ -163,7 +163,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Audit Connection', 'display_name' => 'Audit Connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$draft = createOnboardingDraft([ $draft = createOnboardingDraft([

View File

@ -67,10 +67,9 @@ public function request(string $method, string $path, array $options = []): Grap
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => 'revoked-audit-tenant-id', 'entra_tenant_id' => 'revoked-audit-tenant-id',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => 'granted', 'consent_status' => 'granted',
'verification_status' => 'healthy', 'verification_status' => 'healthy',
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$run = OperationRun::factory()->create([ $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') expect($connection->consent_status?->value ?? $connection->consent_status)->toBe('revoked')
->and($connection->verification_status?->value ?? $connection->verification_status)->toBe('blocked') ->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->outcome)->toBe(OperationRunOutcome::Blocked->value)
->and($run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentRevoked); ->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') ->latest('id')
->first(); ->first();
$metadata = is_array($log?->metadata ?? null) ? $log->metadata : [];
expect($log)->not->toBeNull() expect($log)->not->toBeNull()
->and($log?->status)->toBe('failed') ->and($log?->status)->toBe('failed')
->and($log?->resource_type)->toBe('provider_connection') ->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['consent_status'] ?? null)->toBe('revoked')
->and($log?->metadata['verification_status'] ?? null)->toBe('blocked') ->and($log?->metadata['verification_status'] ?? null)->toBe('blocked')
->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing); ->and($log?->metadata['detected_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConsentMissing);
expect($metadata)->not->toHaveKey('status')
->and($metadata)->not->toHaveKey('health_status');
}); });

View File

@ -64,8 +64,8 @@ public function request(string $method, string $path, array $options = []): Grap
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => 'verification-audit-tenant-id', 'entra_tenant_id' => 'verification-audit-tenant-id',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => 'granted', 'consent_status' => 'granted',
'status' => 'connected',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -101,16 +101,20 @@ public function request(string $method, string $path, array $options = []): Grap
->latest('id') ->latest('id')
->first(); ->first();
$metadata = is_array($log?->metadata ?? null) ? $log->metadata : [];
expect($log)->not->toBeNull() expect($log)->not->toBeNull()
->and($log?->status)->toBe('success') ->and($log?->status)->toBe('success')
->and($log?->resource_type)->toBe('provider_connection') ->and($log?->resource_type)->toBe('provider_connection')
->and($log?->resource_id)->toBe((string) $connection->getKey()) ->and($log?->resource_id)->toBe((string) $connection->getKey())
->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey()) ->and($log?->metadata['provider_connection_id'] ?? null)->toBe((int) $connection->getKey())
->and($log?->metadata['connection_type'] ?? null)->toBe('platform') ->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['consent_status'] ?? null)->toBe('granted')
->and($log?->metadata['verification_status'] ?? null)->toBe('healthy') ->and($log?->metadata['verification_status'] ?? null)->toBe('healthy')
->and($log?->metadata['effective_client_id'] ?? null)->toBe('platform-client-id') ->and($log?->metadata['effective_client_id'] ?? null)->toBe('platform-client-id')
->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config') ->and($log?->metadata['credential_source'] ?? null)->toBe('platform_config');
->and($log?->metadata['status'] ?? null)->toBe('connected')
->and($log?->metadata['health_status'] ?? null)->toBe('ok'); expect($metadata)->not->toHaveKey('status')
->and($metadata)->not->toHaveKey('health_status');
}); });

View File

@ -195,6 +195,56 @@ public function request(string $method, string $path, array $options = []): Grap
expect($reportCount)->toBe(0); 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 { it('Graph failure marks OperationRun as failed and re-throws', function (): void {
[$user, $tenant] = createUserWithTenant(); [$user, $tenant] = createUserWithTenant();
ensureDefaultProviderConnection($tenant); ensureDefaultProviderConnection($tenant);

View File

@ -61,11 +61,11 @@
expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found'); expect($table->getEmptyStateHeading())->toBe('No Microsoft connections found');
expect($table->getColumn('display_name')?->isSearchable())->toBeTrue(); expect($table->getColumn('display_name')?->isSearchable())->toBeTrue();
expect($table->getColumn('display_name')?->isSortable())->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('status');
expect($visibleColumnNames)->not->toContain('health_status'); expect($visibleColumnNames)->not->toContain('health_status');
expect($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('status'))->toBeNull();
expect($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('health_status'))->toBeNull();
expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('provider')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue(); expect($table->getColumn('entra_tenant_id')?->isToggledHiddenByDefault())->toBeTrue();
expect($table->getColumn('migration_review_required'))->not->toBeNull(); expect($table->getColumn('migration_review_required'))->not->toBeNull();

View File

@ -41,7 +41,7 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'status' => 'needs_consent', 'consent_status' => 'required',
]); ]);
$tenant->makeCurrent(); $tenant->makeCurrent();
@ -66,7 +66,7 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'status' => 'needs_consent', 'consent_status' => 'required',
]); ]);
$tenant->makeCurrent(); $tenant->makeCurrent();

View File

@ -94,7 +94,7 @@ public function request(string $method, string $path, array $options = []): Grap
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $tenant->tenant_id, 'entra_tenant_id' => $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -27,7 +27,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -40,7 +40,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -106,7 +106,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]) Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])

View File

@ -33,10 +33,9 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'display_name' => 'Primary Truth Connection', 'display_name' => 'Primary Truth Connection',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -82,10 +81,9 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'display_name' => 'Truth Cleanup Connection', 'display_name' => 'Truth Cleanup Connection',
'is_default' => true, 'is_default' => true,
'is_enabled' => false,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -100,12 +98,14 @@
->assertSee('Failed') ->assertSee('Failed')
->assertDontSee('App status') ->assertDontSee('App status')
->assertSee('Truth Cleanup Connection') ->assertSee('Truth Cleanup Connection')
->assertSee('Lifecycle')
->assertSee('Disabled')
->assertSee('Granted') ->assertSee('Granted')
->assertSee('Blocked') ->assertSee('Blocked')
->assertSee('Legacy status') ->assertDontSee('Legacy status')
->assertSee('Connected') ->assertDontSee('Connected')
->assertSee('Legacy health') ->assertDontSee('Legacy health')
->assertSee('OK'); ->assertDontSee('OK');
}); });
it('flags tenants that have microsoft connections but no default connection configured', function (): void { it('flags tenants that have microsoft connections but no default connection configured', function (): void {
@ -125,10 +125,9 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'display_name' => 'Fallback Microsoft Connection', 'display_name' => 'Fallback Microsoft Connection',
'is_default' => false, 'is_default' => false,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Healthy->value, 'verification_status' => ProviderVerificationStatus::Healthy->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -161,10 +160,9 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'display_name' => 'Blocked Connection', 'display_name' => 'Blocked Connection',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user); $this->actingAs($user);

View File

@ -33,7 +33,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -188,7 +188,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -253,7 +253,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -28,7 +28,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -28,7 +28,7 @@ function makeTenantWithDefaultProviderConnection(array $attributes = []): Tenant
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -1841,7 +1841,7 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft', 'provider' => 'microsoft',
'status' => 'connected', 'is_enabled' => true,
'is_default' => false, 'is_default' => false,
]); ]);

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
it('keeps targeted provider-state runtime paths free of legacy provider status fallbacks', function (): void {
$guardedFiles = [
'apps/platform/app/Models/ProviderConnection.php',
'apps/platform/app/Services/Providers/Contracts/HealthResult.php',
'apps/platform/app/Services/Providers/MicrosoftProviderHealthCheck.php',
'apps/platform/app/Services/Providers/ProviderConnectionMutationService.php',
'apps/platform/app/Services/Providers/ProviderConnectionResolver.php',
'apps/platform/app/Services/Providers/ProviderConnectionStateProjector.php',
'apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php',
'apps/platform/app/Filament/Resources/ProviderConnectionResource.php',
'apps/platform/app/Filament/Resources/TenantResource.php',
'apps/platform/app/Filament/System/Pages/Directory/Tenants.php',
'apps/platform/app/Filament/System/Pages/Directory/ViewTenant.php',
'apps/platform/resources/views/filament/infolists/entries/provider-connection-state.blade.php',
'apps/platform/resources/views/filament/system/pages/directory/view-tenant.blade.php',
];
$forbiddenNeedles = [
'health_status',
'BadgeDomain::ProviderConnectionStatus',
'BadgeDomain::ProviderConnectionHealth',
'ProviderConnectionStatusBadge',
'ProviderConnectionHealthBadge',
"'status' => '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([]);
});

View File

@ -96,7 +96,7 @@ function executeInventorySyncNow(Tenant $tenant, array $selection): array
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $tenant->tenant_id, 'entra_tenant_id' => $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -145,7 +145,7 @@
'entra_tenant_id' => $entraTenantId, 'entra_tenant_id' => $entraTenantId,
'display_name' => 'Platform onboarding connection', 'display_name' => 'Platform onboarding connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'is_enabled' => true,
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -30,7 +30,7 @@
->firstOrFail(); ->firstOrFail();
expect($connection->connection_type)->toBe(ProviderConnectionType::Platform) 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->consent_status)->toBe(ProviderConsentStatus::Granted)
->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown) ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown)
->and($connection->credential()->exists())->toBeFalse() ->and($connection->credential()->exists())->toBeFalse()
@ -55,7 +55,7 @@
->firstOrFail(); ->firstOrFail();
expect($connection->connection_type)->toBe(ProviderConnectionType::Platform) 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->consent_status)->toBe(ProviderConsentStatus::Failed)
->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown) ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown)
->and($connection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed) ->and($connection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderAuthFailed)

View File

@ -50,7 +50,7 @@
'entra_tenant_id' => $entraTenantId, 'entra_tenant_id' => $entraTenantId,
'display_name' => 'Acme connection', 'display_name' => 'Acme connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -195,7 +195,7 @@
'entra_tenant_id' => $entraTenantId, 'entra_tenant_id' => $entraTenantId,
'display_name' => 'Blocked by report connection', 'display_name' => 'Blocked by report connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -290,7 +290,7 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Ready connection', 'display_name' => 'Ready connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -453,7 +453,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -531,7 +531,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -346,7 +346,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -62,6 +62,7 @@
->firstOrFail(); ->firstOrFail();
expect($connection->connection_type)->toBe(ProviderConnectionType::Platform) expect($connection->connection_type)->toBe(ProviderConnectionType::Platform)
->and($connection->is_enabled)->toBeTrue()
->and($connection->consent_status)->toBe(ProviderConsentStatus::Required) ->and($connection->consent_status)->toBe(ProviderConsentStatus::Required)
->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown) ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Unknown)
->and($connection->credential()->exists())->toBeFalse(); ->and($connection->credential()->exists())->toBeFalse();

View File

@ -81,7 +81,7 @@ function createVerificationAssistDraft(
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verified connection', 'display_name' => 'Verified connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$selectedConnection = $verifiedConnection; $selectedConnection = $verifiedConnection;
@ -94,7 +94,7 @@ function createVerificationAssistDraft(
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Current selected connection', 'display_name' => 'Current selected connection',
'is_default' => false, 'is_default' => false,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
} }

View File

@ -54,7 +54,6 @@
ProviderConnection::query() ProviderConnection::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->update([ ->update([
'status' => 'connected',
'consent_status' => 'granted', 'consent_status' => 'granted',
'last_error_reason_code' => null, 'last_error_reason_code' => null,
'last_error_message' => null, 'last_error_message' => null,
@ -131,14 +130,14 @@
$tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail(); $tenant = Tenant::query()->where('tenant_id', $entraTenantId)->firstOrFail();
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->dedicated()->create([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => $entraTenantId, 'entra_tenant_id' => $entraTenantId,
'display_name' => 'Blocked connection', 'display_name' => 'Blocked connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$component->call('selectProviderConnection', (int) $connection->getKey()); $component->call('selectProviderConnection', (int) $connection->getKey());
@ -208,7 +207,6 @@
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'display_name' => 'Verified connection', 'display_name' => 'Verified connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected',
]); ]);
$draft = createOnboardingDraft([ $draft = createOnboardingDraft([
@ -278,7 +276,6 @@
'entra_tenant_id' => $entraTenantId, 'entra_tenant_id' => $entraTenantId,
'display_name' => 'Contoso platform connection', 'display_name' => 'Contoso platform connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -361,7 +358,6 @@
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'is_default' => true, 'is_default' => true,
'status' => 'connected',
'consent_status' => 'granted', 'consent_status' => 'granted',
]); ]);
@ -537,7 +533,6 @@
'entra_tenant_id' => (string) $otherTenant->tenant_id, 'entra_tenant_id' => (string) $otherTenant->tenant_id,
'display_name' => 'Forged verification connection', 'display_name' => 'Forged verification connection',
'is_default' => true, 'is_default' => true,
'status' => 'connected',
]); ]);
$otherDraft = TenantOnboardingSession::query()->create([ $otherDraft = TenantOnboardingSession::query()->create([

View File

@ -33,7 +33,7 @@ function runQueuedContractMatrixJobThroughMiddleware(object $job, Closure $termi
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = app(OperationRunService::class)->ensureRun( $run = app(OperationRunService::class)->ensureRun(
@ -205,7 +205,7 @@ function () use (&$terminalInvoked): string {
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$result = app(ProviderOperationStartGate::class)->start( $result = app(ProviderOperationStartGate::class)->start(

View File

@ -25,7 +25,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySyncReport(array $attribut
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()), 'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
]); ]);

View File

@ -24,7 +24,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySync(array $attributes = [
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()), 'entra_tenant_id' => (string) ($tenant->tenant_id ?: 'tenant-'.$tenant->getKey()),
]); ]);

View File

@ -15,7 +15,7 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'status' => 'disabled', 'is_enabled' => false,
'provider' => 'microsoft', 'provider' => 'microsoft',
]); ]);

View File

@ -31,6 +31,9 @@
expect($created)->not->toBeNull(); expect($created)->not->toBeNull();
expect($created?->provider)->toBe('microsoft'); 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); $listComponent = Livewire::test(ListProviderConnections::class);
$providerFilter = $listComponent->instance()->getTable()->getFilters()['provider'] ?? null; $providerFilter = $listComponent->instance()->getTable()->getFilters()['provider'] ?? null;

View File

@ -41,7 +41,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$result = app(ProviderOperationStartGate::class)->start( $result = app(ProviderOperationStartGate::class)->start(
@ -67,7 +67,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $first->getKey(), 'provider_connection_id' => (int) $first->getKey(),
@ -77,7 +77,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $second->getKey(), 'provider_connection_id' => (int) $second->getKey(),

View File

@ -1,14 +1,18 @@
<?php <?php
use App\Models\AuditLog;
use App\Filament\Resources\ProviderConnectionResource; use App\Filament\Resources\ProviderConnectionResource;
use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection; use App\Filament\Resources\ProviderConnectionResource\Pages\EditProviderConnection;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\ProviderCredential; use App\Models\ProviderCredential;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
it('enables a disabled connection and sets needs_consent when credentials are missing', function (): void { it('enables a disabled connection and records verification as blocked when credentials are missing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
@ -19,8 +23,9 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'disabled', 'is_enabled' => false,
'health_status' => 'down', 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Error->value,
'last_health_check_at' => now(), 'last_health_check_at' => now(),
'last_error_reason_code' => 'provider_auth_failed', 'last_error_reason_code' => 'provider_auth_failed',
'last_error_message' => 'Some failure', 'last_error_message' => 'Some failure',
@ -30,15 +35,27 @@
->callAction('enable_connection'); ->callAction('enable_connection');
$connection->refresh(); $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->is_enabled)->toBeTrue()
expect($connection->health_status)->toBe('unknown'); ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted)
expect($connection->last_health_check_at)->toBeNull(); ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Blocked)
expect($connection->last_error_reason_code)->toBeNull(); ->and($connection->last_health_check_at)->toBeNull()
expect($connection->last_error_message)->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'); [$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user); $this->actingAs($user);
@ -49,8 +66,9 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'disabled', 'is_enabled' => false,
'health_status' => 'down', 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Error->value,
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -65,9 +83,56 @@
->callAction('enable_connection'); ->callAction('enable_connection');
$connection->refresh(); $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->is_enabled)->toBeTrue()
expect($connection->health_status)->toBe('unknown'); ->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 { it('shows a link to the last connection check run when present', function (): void {

View File

@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
use App\Jobs\ProviderConnectionHealthCheckJob; use App\Jobs\ProviderConnectionHealthCheckJob;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
@ -9,6 +11,8 @@
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderVerificationStatus;
it('updates connection health and marks the run succeeded on success', function (): void { it('updates connection health and marks the run succeeded on success', function (): void {
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface app()->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(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => ProviderVerificationStatus::Unknown->value,
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -91,11 +95,12 @@ public function request(string $method, string $path, array $options = []): Grap
$connection->refresh(); $connection->refresh();
$run->refresh(); $run->refresh();
expect($connection->status)->toBe('connected'); expect($connection->is_enabled)->toBeTrue()
expect($connection->health_status)->toBe('ok'); ->and($connection->consent_status)->toBe(ProviderConsentStatus::Granted)
expect($connection->last_health_check_at)->not->toBeNull(); ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Healthy)
expect($connection->last_error_reason_code)->toBeNull(); ->and($connection->last_health_check_at)->not->toBeNull()
expect($connection->last_error_message)->toBeNull(); ->and($connection->last_error_reason_code)->toBeNull()
->and($connection->last_error_message)->toBeNull();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded'); expect($run->outcome)->toBe('succeeded');
@ -155,8 +160,9 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
'health_status' => 'ok', 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Healthy->value,
]); ]);
ProviderCredential::factory()->create([ 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)); $job->handle(app(\App\Services\Providers\MicrosoftProviderHealthCheck::class), app(OperationRunService::class));
$connection->refresh();
$run->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->status)->toBe(OperationRunStatus::Completed->value);
expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value); expect($run->outcome)->toBe(OperationRunOutcome::Blocked->value);
$context = is_array($run->context ?? null) ? $run->context : []; $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; $nextSteps = $context['next_steps'] ?? null;
expect($nextSteps)->toBeArray(); expect($nextSteps)->toBeArray();
@ -263,8 +273,8 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => ProviderVerificationStatus::Unknown->value,
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -354,8 +364,8 @@ public function request(string $method, string $path, array $options = []): Grap
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
'health_status' => 'unknown', 'verification_status' => ProviderVerificationStatus::Unknown->value,
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -395,9 +405,9 @@ public function request(string $method, string $path, array $options = []): Grap
$connection->refresh(); $connection->refresh();
$run->refresh(); $run->refresh();
expect($connection->status)->toBe('error'); expect($connection->consent_status)->toBe(ProviderConsentStatus::Granted)
expect($connection->health_status)->toBe('down'); ->and($connection->verification_status)->toBe(ProviderVerificationStatus::Error)
expect($connection->last_error_reason_code)->toBe('provider_auth_failed'); ->and($connection->last_error_reason_code)->toBe('provider_auth_failed');
expect((string) $connection->last_error_message) expect((string) $connection->last_error_message)
->not->toContain('Authorization') ->not->toContain('Authorization')
->not->toContain('Bearer ') ->not->toContain('Bearer ')

View File

@ -32,7 +32,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
]); ]);
Livewire::test(ListProviderConnections::class) Livewire::test(ListProviderConnections::class)
@ -78,7 +78,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
]); ]);
$component = Livewire::test(ListProviderConnections::class); $component = Livewire::test(ListProviderConnections::class);
@ -107,7 +107,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'is_enabled' => true,
]); ]);
Livewire::test(ListProviderConnections::class) Livewire::test(ListProviderConnections::class)

View File

@ -113,9 +113,9 @@
expect($hybridConnection->connection_type->value)->toBe('dedicated') expect($hybridConnection->connection_type->value)->toBe('dedicated')
->and($hybridConnection->migration_review_required)->toBeTrue() ->and($hybridConnection->migration_review_required)->toBeTrue()
->and($hybridConnection->verification_status->value)->toBe('blocked') ->and($hybridConnection->verification_status->value)->toBe('blocked')
->and($hybridConnection->status)->toBe('error') ->and($hybridConnection->is_enabled)->toBeTrue()
->and($hybridConnection->health_status)->toBe('down')
->and($hybridConnection->last_error_reason_code)->toBe(ProviderReasonCodes::ProviderConnectionReviewRequired) ->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([ ->and($hybridConnection->metadata)->toMatchArray([
'legacy_identity_review_required' => true, 'legacy_identity_review_required' => true,
'legacy_identity_result' => 'dedicated', 'legacy_identity_result' => 'dedicated',

View File

@ -22,10 +22,9 @@
'display_name' => 'Contoso', 'display_name' => 'Contoso',
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Degraded->value, 'verification_status' => ProviderVerificationStatus::Degraded->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -42,11 +41,11 @@
->values() ->values()
->all(); ->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('status')
->and($visibleColumnNames)->not->toContain('health_status') ->and($visibleColumnNames)->not->toContain('health_status')
->and($table->getColumn('status')?->isToggledHiddenByDefault())->toBeTrue() ->and($table->getColumn('status'))->toBeNull()
->and($table->getColumn('health_status')?->isToggledHiddenByDefault())->toBeTrue(); ->and($table->getColumn('health_status'))->toBeNull();
}); });
it('separates current state from diagnostics on the provider connection view page', function (): void { it('separates current state from diagnostics on the provider connection view page', function (): void {
@ -58,10 +57,9 @@
'display_name' => 'Truthful Connection', 'display_name' => 'Truthful Connection',
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Degraded->value, 'verification_status' => ProviderVerificationStatus::Degraded->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user) $this->actingAs($user)
@ -69,15 +67,14 @@
->assertOk() ->assertOk()
->assertSeeInOrder([ ->assertSeeInOrder([
'Current state', 'Current state',
'Lifecycle',
'Enabled',
'Consent', 'Consent',
'Granted', 'Granted',
'Verification', 'Verification',
'Degraded', 'Degraded',
'Diagnostics', 'Diagnostics',
'Legacy status', 'Last error reason',
'Connected',
'Legacy health',
'OK',
]); ]);
}); });
@ -90,10 +87,9 @@
'display_name' => 'Editable Truthful Connection', 'display_name' => 'Editable Truthful Connection',
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Blocked->value, 'verification_status' => ProviderVerificationStatus::Blocked->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user) $this->actingAs($user)
@ -101,15 +97,14 @@
->assertOk() ->assertOk()
->assertSeeInOrder([ ->assertSeeInOrder([
'Current state', 'Current state',
'Lifecycle',
'Enabled',
'Consent', 'Consent',
'Granted', 'Granted',
'Verification', 'Verification',
'Blocked', 'Blocked',
'Diagnostics', 'Diagnostics',
'Legacy status', 'Last error reason',
'Connected',
'Legacy health',
'OK',
]); ]);
}); });
@ -122,10 +117,9 @@
'display_name' => 'Unknown Verification Connection', 'display_name' => 'Unknown Verification Connection',
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'is_enabled' => true,
'consent_status' => ProviderConsentStatus::Granted->value, 'consent_status' => ProviderConsentStatus::Granted->value,
'verification_status' => ProviderVerificationStatus::Unknown->value, 'verification_status' => ProviderVerificationStatus::Unknown->value,
'status' => 'connected',
'health_status' => 'ok',
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -145,3 +139,39 @@
->assertSee('Unknown') ->assertSee('Unknown')
->assertDontSee('Ready'); ->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');
});

View File

@ -15,7 +15,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'display_name' => 'Spec081 Connection', 'display_name' => 'Spec081 Connection',
'provider' => 'microsoft', 'provider' => 'microsoft',
'status' => 'connected', 'is_enabled' => true,
'migration_review_required' => true, 'migration_review_required' => true,
'metadata' => [ 'metadata' => [
'legacy_identity_classification_source' => 'tenantpilot:provider-connections:classify', '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')) $this->get(ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => $connection], panel: 'admin'))
->assertOk() ->assertOk()
->assertSee('Spec081 Connection') ->assertSee('Spec081 Connection')
->assertSee('Lifecycle')
->assertSee('Enabled')
->assertSee('Verification')
->assertSee('Migration review') ->assertSee('Migration review')
->assertSee('Review required'); ->assertSee('Review required')
->assertDontSee('Diagnostic status')
->assertDontSee('Diagnostic health');
}); });
Bus::assertNothingDispatched(); Bus::assertNothingDispatched();

View File

@ -44,7 +44,7 @@ function spec081TenantWithDefaultMicrosoftConnection(string $tenantId): array
'workspace_id' => (int) $tenant->workspace_id, 'workspace_id' => (int) $tenant->workspace_id,
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
'entra_tenant_id' => $tenantId, 'entra_tenant_id' => $tenantId,
]); ]);

View File

@ -25,7 +25,7 @@
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'status' => 'connected', 'consent_status' => 'granted',
'is_default' => true, 'is_default' => true,
]); ]);

View File

@ -33,7 +33,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$component = Livewire::test(ListProviderConnections::class); $component = Livewire::test(ListProviderConnections::class);
@ -86,7 +86,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$component = Livewire::test(ListProviderConnections::class); $component = Livewire::test(ListProviderConnections::class);
@ -130,7 +130,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$component = Livewire::test(ListProviderConnections::class); $component = Livewire::test(ListProviderConnections::class);

View File

@ -35,9 +35,9 @@
$filters = $component->instance()->getTable()->getFilters(); $filters = $component->instance()->getTable()->getFilters();
$filterNames = array_keys($filters); $filterNames = array_keys($filters);
expect($filterNames)->toContain('tenant', 'provider', 'consent_status', 'verification_status', 'status', 'health_status', 'default_only'); expect($filterNames)->toContain('tenant', 'provider', 'consent_status', 'verification_status', 'is_enabled', 'default_only');
expect($filters['status']->getLabel())->toBe('Diagnostic status'); expect($filterNames)->not->toContain('status', 'health_status');
expect($filters['health_status']->getLabel())->toBe('Diagnostic health'); expect($filters['is_enabled']->getLabel())->toBe('Lifecycle');
$component $component
->set('tableFilters.default_only.isActive', true) ->set('tableFilters.default_only.isActive', true)

View File

@ -80,7 +80,8 @@
'tenant_id' => (int) $tenantA->getKey(), 'tenant_id' => (int) $tenantA->getKey(),
'display_name' => 'A Connected', 'display_name' => 'A Connected',
'provider' => 'microsoft', 'provider' => 'microsoft',
'status' => 'connected', 'consent_status' => 'granted',
'verification_status' => 'healthy',
]); ]);
$tenantBConnected = ProviderConnection::factory()->create([ $tenantBConnected = ProviderConnection::factory()->create([
@ -88,7 +89,8 @@
'tenant_id' => (int) $tenantB->getKey(), 'tenant_id' => (int) $tenantB->getKey(),
'display_name' => 'B Connected', 'display_name' => 'B Connected',
'provider' => 'microsoft', 'provider' => 'microsoft',
'status' => 'connected', 'consent_status' => 'granted',
'verification_status' => 'healthy',
]); ]);
$tenantBFailed = ProviderConnection::factory()->create([ $tenantBFailed = ProviderConnection::factory()->create([
@ -96,7 +98,8 @@
'tenant_id' => (int) $tenantB->getKey(), 'tenant_id' => (int) $tenantB->getKey(),
'display_name' => 'B Failed', 'display_name' => 'B Failed',
'provider' => 'microsoft', 'provider' => 'microsoft',
'status' => 'error', 'consent_status' => 'granted',
'verification_status' => 'error',
]); ]);
$this->actingAs($user); $this->actingAs($user);
@ -104,7 +107,7 @@
Livewire::withQueryParams([ Livewire::withQueryParams([
'tenant_id' => (string) $tenantB->external_id, 'tenant_id' => (string) $tenantB->external_id,
])->test(ListProviderConnections::class) ])->test(ListProviderConnections::class)
->filterTable('status', 'connected') ->filterTable('verification_status', 'healthy')
->assertCanSeeTableRecords([$tenantBConnected]) ->assertCanSeeTableRecords([$tenantBConnected])
->assertCanNotSeeTableRecords([$tenantAConnection, $tenantBFailed]); ->assertCanNotSeeTableRecords([$tenantAConnection, $tenantBFailed]);
}); });

View File

@ -36,7 +36,7 @@
'workspace_id' => $tenant->workspace_id, 'workspace_id' => $tenant->workspace_id,
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'needs_consent', 'consent_status' => 'required',
]); ]);
$resolver = app(MicrosoftGraphOptionsResolver::class); $resolver = app(MicrosoftGraphOptionsResolver::class);
@ -61,7 +61,6 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => 'entra-tenant-id', 'entra_tenant_id' => 'entra-tenant-id',
'is_default' => true, 'is_default' => true,
'status' => 'connected',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -17,7 +17,7 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'status' => 'disabled', 'is_enabled' => false,
]); ]);
$this->get('/admin/provider-connections/'.$connection->getKey().'/edit') $this->get('/admin/provider-connections/'.$connection->getKey().'/edit')
@ -33,7 +33,7 @@
$connection = ProviderConnection::factory()->create([ $connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'status' => 'disabled', 'is_enabled' => false,
]); ]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])

View File

@ -186,7 +186,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([
@ -260,7 +260,7 @@
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
$run = OperationRun::factory()->create([ $run = OperationRun::factory()->create([

View File

@ -3,9 +3,13 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\Workspace; use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities; 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; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -23,19 +27,65 @@
->assertForbidden(); ->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']); $workspace = Workspace::factory()->create(['name' => 'Directory Workspace']);
Tenant::factory()->create([ $criticalTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'name' => 'Contoso', 'name' => 'A Critical Tenant',
'status' => Tenant::STATUS_ACTIVE, 'status' => Tenant::STATUS_ACTIVE,
]); ]);
Tenant::factory()->create([ $warningTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(), 'workspace_id' => (int) $workspace->getKey(),
'name' => 'Fabrikam', 'name' => 'B Warning Tenant',
'status' => Tenant::STATUS_ONBOARDING, '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([ $platformUser = PlatformUser::factory()->create([
@ -49,6 +99,52 @@
$this->actingAs($platformUser, 'platform') $this->actingAs($platformUser, 'platform')
->get('/system/directory/tenants') ->get('/system/directory/tenants')
->assertSuccessful() ->assertSuccessful()
->assertSee('Contoso') ->assertSeeInOrder([
->assertSee('Fabrikam'); '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');
}); });

View File

@ -3,6 +3,9 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Resources\TenantResource; 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 { it('renders provider connections CTA with canonical tenantless URL on tenant detail page', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -34,3 +37,37 @@
->assertOk() ->assertOk()
->assertSee('/admin/provider-connections?tenant_id='.(string) $tenant->external_id, false); ->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');
});

View File

@ -36,7 +36,7 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
Livewire::test(ListProviderConnections::class) Livewire::test(ListProviderConnections::class)
@ -69,7 +69,7 @@ function runQueuedJobThroughMiddleware(object $job, Closure $terminal): mixed
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
Livewire::test(ListProviderConnections::class) Livewire::test(ListProviderConnections::class)
@ -120,7 +120,7 @@ function (ProviderConnectionHealthCheckJob $job) use (&$terminalInvoked): mixed
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
Livewire::test(ListProviderConnections::class) Livewire::test(ListProviderConnections::class)

View File

@ -95,7 +95,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),

View File

@ -26,7 +26,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),

View File

@ -23,7 +23,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
@ -70,7 +70,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(), 'entra_tenant_id' => fake()->uuid(),
'status' => 'connected', 'consent_status' => 'granted',
'is_default' => true, 'is_default' => true,
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([

View File

@ -584,8 +584,8 @@ function ensureDefaultProviderConnection(
$updates['is_default'] = true; $updates['is_default'] = true;
} }
if ($connection->status !== 'connected') { if (! $connection->is_enabled) {
$updates['status'] = 'connected'; $updates['is_enabled'] = true;
} }
if ($currentConsentStatus !== ProviderConsentStatus::Granted->value) { if ($currentConsentStatus !== ProviderConsentStatus::Granted->value) {
@ -596,10 +596,6 @@ function ensureDefaultProviderConnection(
$updates['verification_status'] = ProviderVerificationStatus::Healthy->value; $updates['verification_status'] = ProviderVerificationStatus::Healthy->value;
} }
if ($connection->health_status !== 'ok') {
$updates['health_status'] = 'ok';
}
if ($entraTenantId === '') { if ($entraTenantId === '') {
$updates['entra_tenant_id'] = (string) ($tenant->tenant_id ?? fake()->uuid()); $updates['entra_tenant_id'] = (string) ($tenant->tenant_id ?? fake()->uuid());
} }

View File

@ -39,3 +39,12 @@
expect(BadgeCatalog::mapper(BadgeDomain::InventoryCoverageState))->not->toBeNull() expect(BadgeCatalog::mapper(BadgeDomain::InventoryCoverageState))->not->toBeNull()
->and(BadgeCatalog::spec(BadgeDomain::InventoryCoverageState, 'failed')->label)->toBe('Failed'); ->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');
});

View File

@ -39,40 +39,16 @@
$blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked'); $blocked = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked');
expect($blocked->color)->toBe('danger'); expect($blocked->color)->toBe('danger');
expect($blocked->label)->toBe('Blocked'); expect($blocked->label)->toBe('Blocked');
});
it('maps provider connection legacy status safely', function (): void { $degraded = BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'degraded');
$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');
expect($degraded->color)->toBe('warning'); expect($degraded->color)->toBe('warning');
expect($degraded->label)->toBe('Degraded'); expect($degraded->label)->toBe('Degraded');
});
$down = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'down');
expect($down->color)->toBe('danger'); it('does not expose legacy provider status badge domains anymore', function (): void {
expect($down->label)->toBe('Down'); $domainValues = collect(BadgeDomain::cases())
->map(fn (BadgeDomain $domain): string => $domain->value)
$unknown = BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown'); ->all();
expect($unknown->color)->toBe('gray');
expect($unknown->label)->toBe('Unknown'); expect($domainValues)->not->toContain('provider_connection.status', 'provider_connection.health');
}); });

View File

@ -24,7 +24,7 @@ function tenantWithDefaultMicrosoftConnectionForPolicySnapshot(string $tenantIde
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
'entra_tenant_id' => $tenantIdentifier, 'entra_tenant_id' => $tenantIdentifier,
]); ]);

View File

@ -5,18 +5,17 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
it('normalizes provider connection status badges from consent and verification semantics', function (): void { it('normalizes provider consent aliases through the canonical consent badge domain', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'required')->label)->toBe('Needs consent') expect(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'needs_consent')->label)->toBe('Required')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'revoked')->label)->toBe('Error') ->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'connected')->label)->toBe('Granted')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'blocked')->label)->toBe('Error') ->and(BadgeCatalog::spec(BadgeDomain::ProviderConsentStatus, 'error')->label)->toBe('Failed');
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionStatus, 'connected')->label)->toBe('Connected');
}); });
it('normalizes provider connection health badges from verification semantics', function (): void { it('normalizes provider verification aliases through the canonical verification badge domain', function (): void {
expect(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'healthy')->label)->toBe('OK') expect(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'ok')->label)->toBe('Healthy')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'blocked')->label)->toBe('Down') ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'warning')->label)->toBe('Degraded')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'error')->label)->toBe('Down') ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'failed')->label)->toBe('Error')
->and(BadgeCatalog::spec(BadgeDomain::ProviderConnectionHealth, 'unknown')->label)->toBe('Unknown'); ->and(BadgeCatalog::spec(BadgeDomain::ProviderVerificationStatus, 'blocked')->label)->toBe('Blocked');
}); });
it('maps managed-tenant onboarding verification badge aliases consistently', function (): void { it('maps managed-tenant onboarding verification badge aliases consistently', function (): void {

View File

@ -92,7 +92,6 @@
'entra_tenant_id' => 'hybrid-tenant-id', 'entra_tenant_id' => 'hybrid-tenant-id',
'consent_status' => 'granted', 'consent_status' => 'granted',
'verification_status' => 'healthy', 'verification_status' => 'healthy',
'status' => 'connected',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
@ -119,8 +118,8 @@
->and($result->signals)->toMatchArray([ ->and($result->signals)->toMatchArray([
'tenant_client_id' => 'legacy-tenant-client-id', 'tenant_client_id' => 'legacy-tenant-client-id',
'credential_client_id' => 'dedicated-client-id', 'credential_client_id' => 'dedicated-client-id',
'is_enabled' => true,
'consent_status' => 'granted', 'consent_status' => 'granted',
'verification_status' => 'healthy', 'verification_status' => 'healthy',
'status' => 'connected',
]); ]);
}); });

View File

@ -19,7 +19,7 @@
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'entra_tenant_id' => 'entra-tenant-id', 'entra_tenant_id' => 'entra-tenant-id',
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
@ -62,7 +62,7 @@
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
@ -99,7 +99,7 @@
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),

View File

@ -27,7 +27,7 @@ function fakeTenant(): Tenant
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
'entra_tenant_id' => (string) $tenant->tenant_id, 'entra_tenant_id' => (string) $tenant->tenant_id,
]); ]);

View File

@ -23,7 +23,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
@ -71,7 +71,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
@ -117,7 +117,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),
@ -146,7 +146,7 @@
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'provider' => 'microsoft', 'provider' => 'microsoft',
'is_default' => true, 'is_default' => true,
'status' => 'connected', 'consent_status' => 'granted',
]); ]);
ProviderCredential::factory()->create([ ProviderCredential::factory()->create([
'provider_connection_id' => (int) $connection->getKey(), 'provider_connection_id' => (int) $connection->getKey(),

View File

@ -3,7 +3,7 @@ # Product Principles
> Permanent product principles that govern every spec, every UI decision, and every architectural choice. > 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. > 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. 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. 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 ### 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. 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. Otherwise they remain derived presentation.

View File

@ -3,7 +3,7 @@ # Product Roadmap
> Strategic thematic blocks and release trajectory. > Strategic thematic blocks and release trajectory.
> This is the "big picture" — not individual specs. > 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. **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. **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. **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 ### Entra Role Governance
Expand TenantPilot's governance coverage into Microsoft Entra role definitions and assignments as a first-class identity administration surface. Expand TenantPilot's governance coverage into Microsoft Entra role definitions and assignments as a first-class identity administration surface.

View File

@ -5,7 +5,7 @@ # Spec Candidates
> >
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec` > **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
**Last reviewed**: 2026-04-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) - **Dependencies**: Findings workflow (Spec 111) complete, audit log foundation (Spec 134)
- **Priority**: high - **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 ### Compliance Readiness & Executive Review Packs
- **Type**: feature - **Type**: feature
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance - **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More