From 74e75c3edf43a567a619f8c12bf0d7e422808c08 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Fri, 8 May 2026 11:25:53 +0200 Subject: [PATCH] feat: implement provider capability registry --- .../ManagedTenantOnboardingWizard.php | 77 ++- .../Resources/ProviderConnectionResource.php | 93 ++++ .../Resources/TenantReviewResource.php | 13 +- ...antRequiredPermissionsViewModelBuilder.php | 116 +++++ .../Providers/ProviderOperationRegistry.php | 28 +- .../Providers/ProviderOperationStartGate.php | 44 ++ .../app/Support/Badges/BadgeCatalog.php | 1 + .../app/Support/Badges/BadgeDomain.php | 1 + .../Domains/ProviderCapabilityStatusBadge.php | 24 + .../ProviderCapabilityDefinition.php | 40 ++ .../ProviderCapabilityEvaluator.php | 456 ++++++++++++++++++ .../ProviderCapabilityRegistry.php | 114 +++++ .../Capabilities/ProviderCapabilityResult.php | 70 +++ .../Capabilities/ProviderCapabilityStatus.php | 33 ++ .../Providers/ProviderReasonTranslator.php | 76 ++- .../ProviderConnectionSurfaceSummary.php | 82 +++- .../TenantPermissionCheckClusters.php | 64 ++- .../VerificationAssistViewModelBuilder.php | 6 + apps/platform/config/provider_boundaries.php | 23 + ...tion-required-permissions-assist.blade.php | 58 +++ .../tenant-required-permissions.blade.php | 65 +++ ...283ProviderCapabilityRegistrySmokeTest.php | 30 ++ ...roviderConnectionCapabilitySummaryTest.php | 49 ++ ...edTenantOnboardingCapabilityAssistTest.php | 47 ++ .../ProviderCapabilityEvaluationTest.php | 124 +++++ .../ProviderOperationCapabilityGateTest.php | 107 ++++ ...uiredPermissionsCapabilityGroupingTest.php | 20 + ...roviderCapabilityReasonTranslationTest.php | 38 ++ .../ProviderCapabilityRegistryTest.php | 37 ++ .../TenantPermissionCapabilityMappingTest.php | 32 ++ .../checklists/requirements.md | 65 +++ ...r-capability-registry.logical.openapi.yaml | 394 +++++++++++++++ .../data-model.md | 192 ++++++++ .../283-provider-capability-registry/plan.md | 302 ++++++++++++ .../quickstart.md | 74 +++ .../research.md | 68 +++ .../283-provider-capability-registry/spec.md | 372 ++++++++++++++ .../283-provider-capability-registry/tasks.md | 234 +++++++++ 38 files changed, 3638 insertions(+), 31 deletions(-) create mode 100644 apps/platform/app/Support/Badges/Domains/ProviderCapabilityStatusBadge.php create mode 100644 apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityDefinition.php create mode 100644 apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php create mode 100644 apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php create mode 100644 apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityResult.php create mode 100644 apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityStatus.php create mode 100644 apps/platform/tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php create mode 100644 apps/platform/tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php create mode 100644 apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php create mode 100644 apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php create mode 100644 apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php create mode 100644 apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php create mode 100644 apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php create mode 100644 apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php create mode 100644 apps/platform/tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php create mode 100644 specs/283-provider-capability-registry/checklists/requirements.md create mode 100644 specs/283-provider-capability-registry/contracts/provider-capability-registry.logical.openapi.yaml create mode 100644 specs/283-provider-capability-registry/data-model.md create mode 100644 specs/283-provider-capability-registry/plan.md create mode 100644 specs/283-provider-capability-registry/quickstart.md create mode 100644 specs/283-provider-capability-registry/research.md create mode 100644 specs/283-provider-capability-registry/spec.md create mode 100644 specs/283-provider-capability-registry/tasks.md diff --git a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php index a1274fbb..6bc005f0 100644 --- a/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php +++ b/apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php @@ -1152,6 +1152,11 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke } $schema = [ + Text::make('Primary provider capability') + ->color('gray'), + Text::make($this->readinessPrimaryCapabilityLine($permissions)) + ->badge() + ->color($this->readinessPrimaryCapabilityColor($permissions)), Text::make('Missing application permissions') ->color('gray'), Text::make((string) $missingApplication) @@ -1204,7 +1209,7 @@ private function readinessPermissionDiagnosticsSchema(array $payload, string $ke * provider_summary: array|null, * verification: array{status: string, status_label: string, run_id: int|null, run_url: string|null, is_active: bool, has_report: bool, matches_selected_connection: bool|null, overall: string|null}, * verification_assist: array{is_visible: bool, reason: string}, - * permissions: array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null}|null, + * permissions: array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, capability_groups: array>, primary_capability_group: array|null, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null}|null, * freshness: array{connection_recently_updated: bool, verification_mismatch: bool, permission_last_refreshed_at: string|null, permission_data_is_stale: bool, note: string}, * blocker: array{reason_code: string|null, blocking_reason_code: string|null, operator_summary: string}, * next_action: array{label: string, kind: string, url: string|null, action_name: string|null, required_capability: string|null}, @@ -1486,7 +1491,7 @@ private function readinessProviderSummary(?ProviderConnection $connection): ?arr } /** - * @return array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null} + * @return array{overall: string|null, counts: array, freshness: array{last_refreshed_at: string|null, is_stale: bool}, capability_groups: array>, primary_capability_group: array|null, missing_permissions: array{application: list, delegated: list}, required_permissions_url: string|null} */ private function readinessPermissionOverview(ManagedEnvironment $tenant): array { @@ -1500,6 +1505,10 @@ private function readinessPermissionOverview(ManagedEnvironment $tenant): array $overview = is_array($viewModel['overview'] ?? null) ? $viewModel['overview'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []; + $capabilityGroups = is_array($overview['capability_groups'] ?? null) ? $overview['capability_groups'] : []; + $primaryCapabilityGroup = is_array($overview['primary_capability_group'] ?? null) + ? $overview['primary_capability_group'] + : null; $permissions = is_array($viewModel['permissions'] ?? null) ? $viewModel['permissions'] : []; return [ @@ -1514,6 +1523,8 @@ private function readinessPermissionOverview(ManagedEnvironment $tenant): array 'last_refreshed_at' => is_string($freshness['last_refreshed_at'] ?? null) ? $freshness['last_refreshed_at'] : null, 'is_stale' => (bool) ($freshness['is_stale'] ?? true), ], + 'capability_groups' => $capabilityGroups, + 'primary_capability_group' => $primaryCapabilityGroup, 'missing_permissions' => [ 'application' => $this->readinessMissingPermissionKeys($permissions, 'application'), 'delegated' => $this->readinessMissingPermissionKeys($permissions, 'delegated'), @@ -1540,6 +1551,54 @@ private function readinessMissingPermissionKeys(array $permissions, string $type ->all(); } + /** + * @param array|null $permissions + */ + private function readinessPrimaryCapabilityLabel(?array $permissions): ?string + { + $primary = is_array($permissions['primary_capability_group'] ?? null) + ? $permissions['primary_capability_group'] + : null; + + if (! is_array($primary)) { + return null; + } + + $label = is_string($primary['label'] ?? null) ? trim((string) $primary['label']) : ''; + + return $label !== '' ? $label : null; + } + + /** + * @param array $permissions + */ + private function readinessPrimaryCapabilityLine(array $permissions): string + { + $primary = is_array($permissions['primary_capability_group'] ?? null) + ? $permissions['primary_capability_group'] + : []; + $label = is_string($primary['label'] ?? null) && trim((string) $primary['label']) !== '' + ? trim((string) $primary['label']) + : 'Provider capability'; + $status = is_string($primary['status'] ?? null) ? trim((string) $primary['status']) : 'unknown'; + $statusLabel = BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, $status)->label; + + return "{$label}: {$statusLabel}"; + } + + /** + * @param array $permissions + */ + private function readinessPrimaryCapabilityColor(array $permissions): string + { + $primary = is_array($permissions['primary_capability_group'] ?? null) + ? $permissions['primary_capability_group'] + : []; + $status = is_string($primary['status'] ?? null) ? trim((string) $primary['status']) : 'unknown'; + + return BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, $status)->color; + } + /** * @param array $permissions * @param 'application'|'delegated' $type @@ -1646,11 +1705,19 @@ private function readinessSummaryText( $permissionOverall = is_string($permissions['overall'] ?? null) ? $permissions['overall'] : null; if ($verificationStatus === 'blocked' || $permissionOverall === VerificationReportOverall::Blocked->value) { - return 'Permission or consent blocker needs attention'; + $capabilityLabel = $this->readinessPrimaryCapabilityLabel($permissions); + + return $capabilityLabel !== null + ? "{$capabilityLabel} capability needs attention" + : 'Permission or consent blocker needs attention'; } if ($permissionOverall === VerificationReportOverall::NeedsAttention->value || (bool) ($permissions['freshness']['is_stale'] ?? false)) { - return 'Readiness needs attention'; + $capabilityLabel = $this->readinessPrimaryCapabilityLabel($permissions); + + return $capabilityLabel !== null + ? "{$capabilityLabel} capability needs refreshed evidence" + : 'Readiness needs attention'; } return match ($lifecycleState) { @@ -1724,7 +1791,7 @@ private function readinessNextAction( if ($permissionOverall === VerificationReportOverall::Blocked->value) { return $this->readinessAction( - label: 'Review permissions', + label: 'Review provider capability', kind: 'review_permissions', url: $draft->tenant instanceof ManagedEnvironment ? RequiredPermissionsLinks::requiredPermissions($draft->tenant) : null, ); diff --git a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php index dfa0ef5d..543ca3c5 100644 --- a/apps/platform/app/Filament/Resources/ProviderConnectionResource.php +++ b/apps/platform/app/Filament/Resources/ProviderConnectionResource.php @@ -519,6 +519,68 @@ private static function providerContextSummary(?ProviderConnection $record): ?st } } + private static function providerCapabilitySummary(?ProviderConnection $record): string + { + if (! $record instanceof ProviderConnection) { + return 'Provider capabilities are evaluated after this connection is saved.'; + } + + try { + return ProviderConnectionSurfaceSummary::forConnection($record)->providerCapabilitySummary(); + } catch (InvalidArgumentException) { + return 'Provider capability needs review'; + } + } + + private static function providerCapabilityStatus(?ProviderConnection $record): string + { + if (! $record instanceof ProviderConnection) { + return 'unknown'; + } + + try { + $summary = ProviderConnectionSurfaceSummary::forConnection($record)->toArray(); + $primary = is_array($summary['primary_provider_capability'] ?? null) + ? $summary['primary_provider_capability'] + : []; + + return (string) ($primary['status'] ?? 'unknown'); + } catch (InvalidArgumentException) { + return 'unknown'; + } + } + + private static function providerCapabilitiesLine(?ProviderConnection $record): string + { + if (! $record instanceof ProviderConnection) { + return 'Provider capabilities are evaluated after this connection is saved.'; + } + + try { + $summary = ProviderConnectionSurfaceSummary::forConnection($record)->toArray(); + $capabilities = is_array($summary['provider_capabilities'] ?? null) + ? $summary['provider_capabilities'] + : []; + } catch (InvalidArgumentException) { + $capabilities = []; + } + + if ($capabilities === []) { + return 'Provider capabilities not evaluated.'; + } + + return collect($capabilities) + ->filter(fn (mixed $capability): bool => is_array($capability)) + ->map(function (array $capability): string { + $label = (string) ($capability['label'] ?? 'Provider capability'); + $status = (string) ($capability['status'] ?? 'unknown'); + $statusLabel = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $status)->label; + + return "{$label}: {$statusLabel}"; + }) + ->implode("\n"); + } + /** * @param array $extra * @return array @@ -597,6 +659,9 @@ public static function form(Schema $schema): Schema Placeholder::make('verification_status_display') ->label('Verification') ->content(fn (?ProviderConnection $record): string => static::verificationStatusLabelFromState($record?->verification_status)), + Placeholder::make('provider_capability_display') + ->label('Provider capability') + ->content(fn (?ProviderConnection $record): string => static::providerCapabilitySummary($record)), Placeholder::make('last_health_check_at_display') ->label('Last check') ->content(fn (?ProviderConnection $record): string => $record?->last_health_check_at?->diffForHumans() ?? 'Never'), @@ -673,6 +738,24 @@ public static function infolist(Schema $schema): Schema ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)), + Infolists\Components\TextEntry::make('provider_capability') + ->label('Provider capability') + ->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record)) + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderCapabilityStatus)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderCapabilityStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderCapabilityStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderCapabilityStatus)) + ->helperText(fn (ProviderConnection $record): string => static::providerCapabilitySummary($record)), + Infolists\Components\TextEntry::make('provider_capability_summary') + ->label('Capability summary') + ->state(fn (ProviderConnection $record): string => static::providerCapabilitySummary($record)) + ->columnSpanFull(), + Infolists\Components\TextEntry::make('provider_capabilities') + ->label('Capability detail') + ->state(fn (ProviderConnection $record): string => static::providerCapabilitiesLine($record)) + ->listWithLineBreaks() + ->columnSpanFull(), Infolists\Components\TextEntry::make('last_health_check_at') ->label('Last check') ->since(), @@ -783,6 +866,16 @@ public static function table(Table $table): Table ->color(BadgeRenderer::color(BadgeDomain::ProviderVerificationStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::ProviderVerificationStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderVerificationStatus)), + Tables\Columns\TextColumn::make('provider_capability') + ->label('Provider capability') + ->state(fn (ProviderConnection $record): string => static::providerCapabilityStatus($record)) + ->badge() + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::ProviderCapabilityStatus)) + ->color(BadgeRenderer::color(BadgeDomain::ProviderCapabilityStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::ProviderCapabilityStatus)) + ->iconColor(BadgeRenderer::iconColor(BadgeDomain::ProviderCapabilityStatus)) + ->description(fn (ProviderConnection $record): string => static::providerCapabilitySummary($record)) + ->toggleable(), Tables\Columns\TextColumn::make('migration_review_required') ->label('Migration review') ->badge() diff --git a/apps/platform/app/Filament/Resources/TenantReviewResource.php b/apps/platform/app/Filament/Resources/TenantReviewResource.php index 5c06453c..8bbfadc4 100644 --- a/apps/platform/app/Filament/Resources/TenantReviewResource.php +++ b/apps/platform/app/Filament/Resources/TenantReviewResource.php @@ -92,16 +92,11 @@ public static function shouldRegisterNavigation(): bool public static function getSlug(?Panel $panel = null): string { - return static::workspaceScopedSlug(parent::getSlug($panel), $panel); - } + $slug = $panel?->getId() === 'admin' + ? 'tenant-reviews' + : parent::getSlug($panel); - public static function getSlug(?Panel $panel = null): string - { - if ($panel?->getId() === 'admin') { - return 'tenant-reviews'; - } - - return parent::getSlug($panel); + return static::workspaceScopedSlug($slug, $panel); } public static function getNavigationGroup(): string diff --git a/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php b/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php index 374976a2..879e478a 100644 --- a/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php +++ b/apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php @@ -3,6 +3,10 @@ namespace App\Services\Intune; use App\Models\ManagedEnvironment; +use App\Support\Providers\Capabilities\ProviderCapabilityDefinition; +use App\Support\Providers\Capabilities\ProviderCapabilityRegistry; +use App\Support\Providers\Capabilities\ProviderCapabilityStatus; +use App\Support\Verification\TenantPermissionCheckClusters; use App\Support\Verification\VerificationReportOverall; use Carbon\CarbonInterface; use Illuminate\Support\Carbon; @@ -12,6 +16,7 @@ class TenantRequiredPermissionsViewModelBuilder /** * @phpstan-type TenantPermissionRow array{key:string,type:'application'|'delegated',description:?string,features:array,status:'granted'|'missing'|'error',details:array|null} * @phpstan-type FeatureImpact array{feature:string,missing:int,required_application:int,required_delegated:int,blocked:bool} + * @phpstan-type CapabilityGroup array{provider_capability_key:string,label:string,status:string,provider_requirement_keys:array,missing_requirement_keys:array,evidence_counts:array{requirements:int,missing:int,errors:int},message:string} * @phpstan-type FilterState array{status:'missing'|'present'|'all',type:'application'|'delegated'|'all',features:array,search:string} * @phpstan-type ViewModel array{ * tenant: array{id:int,external_id:string,name:string}, @@ -19,6 +24,8 @@ class TenantRequiredPermissionsViewModelBuilder * overall: string, * counts: array{missing_application:int,missing_delegated:int,present:int,error:int}, * feature_impacts: array, + * capability_groups: array, + * primary_capability_group: CapabilityGroup|null, * freshness: array{last_refreshed_at:?string,is_stale:bool} * }, * permissions: array, @@ -54,6 +61,7 @@ public function build(ManagedEnvironment $tenant, array $filters = []): array $freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null)); $summaryPermissions = $filteredPermissions; + $capabilityGroups = self::deriveCapabilityGroups($allPermissions, $freshness); return [ 'tenant' => [ @@ -65,6 +73,8 @@ public function build(ManagedEnvironment $tenant, array $filters = []): array 'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)), 'counts' => self::deriveCounts($summaryPermissions), 'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions), + 'capability_groups' => $capabilityGroups, + 'primary_capability_group' => self::primaryCapabilityGroup($capabilityGroups), 'freshness' => $freshness, ], 'permissions' => $filteredPermissions, @@ -76,6 +86,112 @@ public function build(ManagedEnvironment $tenant, array $filters = []): array ]; } + /** + * @param array $permissions + * @param array{last_refreshed_at:?string,is_stale:bool} $freshness + * @return array + */ + public static function deriveCapabilityGroups(array $permissions, array $freshness): array + { + /** @var ProviderCapabilityRegistry $registry */ + $registry = app(ProviderCapabilityRegistry::class); + + return array_map( + static fn (ProviderCapabilityDefinition $definition): array => self::deriveCapabilityGroup( + definition: $definition, + permissions: $permissions, + isStale: (bool) ($freshness['is_stale'] ?? true), + ), + array_values($registry->all()), + ); + } + + /** + * @param array $groups + * @return CapabilityGroup|null + */ + public static function primaryCapabilityGroup(array $groups): ?array + { + if ($groups === []) { + return null; + } + + usort($groups, static function (array $a, array $b): int { + $aStatus = ProviderCapabilityStatus::tryFrom((string) ($a['status'] ?? 'unknown')) ?? ProviderCapabilityStatus::Unknown; + $bStatus = ProviderCapabilityStatus::tryFrom((string) ($b['status'] ?? 'unknown')) ?? ProviderCapabilityStatus::Unknown; + + return $aStatus->priority() <=> $bStatus->priority(); + }); + + return $groups[0]; + } + + /** + * @param array $permissions + * @return CapabilityGroup + */ + private static function deriveCapabilityGroup( + ProviderCapabilityDefinition $definition, + array $permissions, + bool $isStale, + ): array { + $rowsByRequirement = []; + + foreach ($definition->providerRequirementKeys as $requirementKey) { + $rowsByRequirement[$requirementKey] = TenantPermissionCheckClusters::rowsForRequirementKey($permissions, $requirementKey); + } + + $rows = array_values(array_merge(...array_values($rowsByRequirement ?: [[]]))); + $missingRows = array_values(array_filter( + $rows, + static fn (array $row): bool => ($row['status'] ?? null) === 'missing', + )); + $errorRows = array_values(array_filter( + $rows, + static fn (array $row): bool => ($row['status'] ?? null) === 'error', + )); + $missingRequirementKeys = []; + + foreach ($rowsByRequirement as $requirementKey => $requirementRows) { + foreach ($requirementRows as $row) { + if (in_array(($row['status'] ?? null), ['missing', 'error'], true)) { + $missingRequirementKeys[] = (string) $requirementKey; + break; + } + } + } + + $status = match (true) { + $rows === [] => ProviderCapabilityStatus::NotApplicable, + $errorRows !== [] => ProviderCapabilityStatus::Unknown, + $missingRows !== [] => ProviderCapabilityStatus::Missing, + $isStale => ProviderCapabilityStatus::Unknown, + default => ProviderCapabilityStatus::Supported, + }; + + $message = match ($status) { + ProviderCapabilityStatus::Supported => "{$definition->label} capability is supported by stored permission evidence.", + ProviderCapabilityStatus::Missing => "{$definition->label} capability is missing required provider permissions.", + ProviderCapabilityStatus::Unknown => "{$definition->label} capability needs refreshed permission evidence.", + ProviderCapabilityStatus::Blocked => "{$definition->label} capability is blocked.", + ProviderCapabilityStatus::NotApplicable => "{$definition->label} capability has no mapped permission rows for this tenant.", + }; + + return [ + 'provider_capability_key' => $definition->key, + 'label' => $definition->label, + 'status' => $status->value, + 'provider_requirement_keys' => $definition->providerRequirementKeys, + 'missing_requirement_keys' => array_values(array_unique($missingRequirementKeys)), + 'evidence_counts' => [ + 'requirements' => count($rows), + 'missing' => count($missingRows), + 'errors' => count($errorRows), + ], + 'message' => $message, + ]; + } + /** * @param array $permissions */ diff --git a/apps/platform/app/Services/Providers/ProviderOperationRegistry.php b/apps/platform/app/Services/Providers/ProviderOperationRegistry.php index fe68c239..51f97d0d 100644 --- a/apps/platform/app/Services/Providers/ProviderOperationRegistry.php +++ b/apps/platform/app/Services/Providers/ProviderOperationRegistry.php @@ -12,7 +12,7 @@ final class ProviderOperationRegistry public const string BINDING_UNSUPPORTED = 'unsupported'; /** - * @return array + * @return array}> */ public function definitions(): array { @@ -22,42 +22,48 @@ public function definitions(): array 'module' => 'health_check', 'label' => 'Provider connection check', 'required_capability' => Capabilities::PROVIDER_RUN, + 'provider_capability_keys' => ['provider_connection_check'], ], 'inventory.sync' => [ 'operation_type' => 'inventory.sync', 'module' => 'inventory', 'label' => 'Inventory sync', 'required_capability' => Capabilities::PROVIDER_RUN, + 'provider_capability_keys' => ['inventory_read'], ], 'compliance.snapshot' => [ 'operation_type' => 'compliance.snapshot', 'module' => 'compliance', 'label' => 'Compliance snapshot', 'required_capability' => Capabilities::PROVIDER_RUN, + 'provider_capability_keys' => ['configuration_read'], ], 'restore.execute' => [ 'operation_type' => 'restore.execute', 'module' => 'restore', 'label' => 'Restore execution', 'required_capability' => Capabilities::TENANT_MANAGE, + 'provider_capability_keys' => ['restore_execute'], ], 'directory.groups.sync' => [ 'operation_type' => 'directory.groups.sync', 'module' => 'directory_groups', 'label' => 'Directory groups sync', 'required_capability' => Capabilities::TENANT_SYNC, + 'provider_capability_keys' => ['directory_groups_read'], ], 'directory.role_definitions.sync' => [ 'operation_type' => 'directory.role_definitions.sync', 'module' => 'directory_role_definitions', 'label' => 'Role definitions sync', 'required_capability' => Capabilities::TENANT_MANAGE, + 'provider_capability_keys' => ['directory_role_definitions_read'], ], ]; } /** - * @return array + * @return array}> */ public function all(): array { @@ -121,7 +127,7 @@ public function isAllowed(string $operationType): bool } /** - * @return array{operation_type: string, module: string, label: string, required_capability: string} + * @return array{operation_type: string, module: string, label: string, required_capability: string, provider_capability_keys: array} */ public function get(string $operationType): array { @@ -136,6 +142,20 @@ public function get(string $operationType): array return $definition; } + /** + * @return array + */ + public function providerCapabilityKeysForOperation(string $operationType): array + { + $definition = $this->get($operationType); + $keys = $definition['provider_capability_keys'] ?? []; + + return array_values(array_filter( + array_map('strval', is_array($keys) ? $keys : []), + static fn (string $key): bool => trim($key) !== '', + )); + } + /** * @return array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string}|null */ @@ -172,7 +192,7 @@ public function activeBindingFor(string $operationType): ?array /** * @return array{ - * definition: array{operation_type: string, module: string, label: string, required_capability: string}, + * definition: array{operation_type: string, module: string, label: string, required_capability: string, provider_capability_keys: array}, * binding: array{operation_type: string, provider: string, binding_status: string, handler_notes: string, exception_notes: string} * } */ diff --git a/apps/platform/app/Services/Providers/ProviderOperationStartGate.php b/apps/platform/app/Services/Providers/ProviderOperationStartGate.php index 473f2a08..76d8a864 100644 --- a/apps/platform/app/Services/Providers/ProviderOperationStartGate.php +++ b/apps/platform/app/Services/Providers/ProviderOperationStartGate.php @@ -10,6 +10,8 @@ use App\Support\Auth\Capabilities; use App\Support\Operations\ExecutionAuthorityMode; use App\Support\Operations\OperationRunCapabilityResolver; +use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator; +use App\Support\Providers\Capabilities\ProviderCapabilityResult; use App\Support\Providers\ProviderNextStepsRegistry; use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\TargetScope\ProviderConnectionTargetScopeDescriptor; @@ -32,6 +34,7 @@ public function __construct( private readonly ProviderNextStepsRegistry $nextStepsRegistry, private readonly OperationRunCapabilityResolver $capabilityResolver, private readonly ProviderConnectionTargetScopeNormalizer $targetScopeNormalizer, + private readonly ProviderCapabilityEvaluator $providerCapabilityEvaluator, ) {} /** @@ -132,6 +135,32 @@ public function start( return ProviderOperationStartResult::scopeBusy($activeRun); } + $providerCapabilityResults = $this->providerCapabilityEvaluator->evaluateForOperation( + tenant: $tenant, + connection: $lockedConnection, + operationType: $operationType, + ); + $blockingCapability = $this->providerCapabilityEvaluator->primaryBlockingResult($providerCapabilityResults); + + if ($blockingCapability instanceof ProviderCapabilityResult) { + return $this->startBlocked( + tenant: $tenant, + operationType: $operationType, + provider: (string) $binding['provider'], + module: (string) $definition['module'], + reasonCode: $blockingCapability->reasonCode ?? ProviderReasonCodes::ProviderPermissionMissing, + reasonMessage: $blockingCapability->primaryMessage, + connection: $lockedConnection, + initiator: $initiator, + extraContext: array_merge($extraContext, [ + 'provider_binding' => $this->bindingContext($binding), + 'provider_capabilities' => $this->providerCapabilityContext($providerCapabilityResults), + 'provider_capability' => $blockingCapability->toArray(), + 'provider_capability_status' => $blockingCapability->status->value, + ]), + ); + } + $context = array_merge($extraContext, [ 'execution_authority_mode' => is_string($extraContext['execution_authority_mode'] ?? null) ? $extraContext['execution_authority_mode'] @@ -140,6 +169,8 @@ public function start( 'provider' => $lockedConnection->provider, 'module' => $definition['module'], 'provider_binding' => $this->bindingContext($binding), + 'required_provider_capabilities' => $this->registry->providerCapabilityKeysForOperation($operationType), + 'provider_capabilities' => $this->providerCapabilityContext($providerCapabilityResults), 'provider_connection_id' => (int) $lockedConnection->getKey(), 'connection_type' => $lockedConnection->connection_type?->value ?? $lockedConnection->connection_type, 'target_scope' => $this->targetScopeContextForConnection($lockedConnection), @@ -189,6 +220,7 @@ private function startBlocked( 'required_capability' => $this->resolveRequiredCapability($operationType, $extraContext), 'provider' => $provider, 'module' => $module, + 'required_provider_capabilities' => $this->registry->providerCapabilityKeysForOperation($operationType), 'target_scope' => $connection instanceof ProviderConnection ? $this->targetScopeContextForConnection($connection) : $this->targetScopeContextForTenant($tenant, $provider), @@ -239,6 +271,18 @@ private function startBlocked( return ProviderOperationStartResult::blocked($run); } + /** + * @param array $results + * @return array> + */ + private function providerCapabilityContext(array $results): array + { + return array_map( + static fn (ProviderCapabilityResult $result): array => $result->toArray(), + $results, + ); + } + private function invokeDispatcher(callable $dispatcher, OperationRun $run): void { $ref = null; diff --git a/apps/platform/app/Support/Badges/BadgeCatalog.php b/apps/platform/app/Support/Badges/BadgeCatalog.php index f3d081a5..a21eea57 100644 --- a/apps/platform/app/Support/Badges/BadgeCatalog.php +++ b/apps/platform/app/Support/Badges/BadgeCatalog.php @@ -52,6 +52,7 @@ final class BadgeCatalog BadgeDomain::RestoreResultStatus->value => Domains\RestoreResultStatusBadge::class, BadgeDomain::ProviderConsentStatus->value => Domains\ProviderConsentStatusBadge::class, BadgeDomain::ProviderVerificationStatus->value => Domains\ProviderVerificationStatusBadge::class, + BadgeDomain::ProviderCapabilityStatus->value => Domains\ProviderCapabilityStatusBadge::class, BadgeDomain::ManagedTenantOnboardingVerificationStatus->value => Domains\ManagedTenantOnboardingVerificationStatusBadge::class, BadgeDomain::VerificationCheckStatus->value => Domains\VerificationCheckStatusBadge::class, BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class, diff --git a/apps/platform/app/Support/Badges/BadgeDomain.php b/apps/platform/app/Support/Badges/BadgeDomain.php index 21384373..c09ce245 100644 --- a/apps/platform/app/Support/Badges/BadgeDomain.php +++ b/apps/platform/app/Support/Badges/BadgeDomain.php @@ -43,6 +43,7 @@ enum BadgeDomain: string case RestoreResultStatus = 'restore_result_status'; case ProviderConsentStatus = 'provider_connection.consent_status'; case ProviderVerificationStatus = 'provider_connection.verification_status'; + case ProviderCapabilityStatus = 'provider_capability_status'; case ManagedTenantOnboardingVerificationStatus = 'managed_tenant_onboarding.verification_status'; case VerificationCheckStatus = 'verification_check_status'; case VerificationCheckSeverity = 'verification_check_severity'; diff --git a/apps/platform/app/Support/Badges/Domains/ProviderCapabilityStatusBadge.php b/apps/platform/app/Support/Badges/Domains/ProviderCapabilityStatusBadge.php new file mode 100644 index 00000000..9663507d --- /dev/null +++ b/apps/platform/app/Support/Badges/Domains/ProviderCapabilityStatusBadge.php @@ -0,0 +1,24 @@ + new BadgeSpec('Supported', 'success', 'heroicon-m-check-circle'), + 'missing' => new BadgeSpec('Missing', 'warning', 'heroicon-m-exclamation-triangle'), + 'blocked' => new BadgeSpec('Blocked', 'danger', 'heroicon-m-x-circle'), + 'unknown' => new BadgeSpec('Unknown', 'gray', 'heroicon-m-question-mark-circle'), + 'not_applicable' => new BadgeSpec('Not applicable', 'gray', 'heroicon-m-minus-circle'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityDefinition.php b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityDefinition.php new file mode 100644 index 00000000..f952180a --- /dev/null +++ b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityDefinition.php @@ -0,0 +1,40 @@ + $operationTypes + * @param array $providerRequirementKeys + */ + public function __construct( + public string $key, + public string $label, + public array $operationTypes, + public array $providerRequirementKeys, + public string $defaultReasonCode, + ) {} + + /** + * @return array{ + * key: string, + * label: string, + * operation_types: array, + * provider_requirement_keys: array, + * default_reason_code: string + * } + */ + public function toArray(): array + { + return [ + 'key' => $this->key, + 'label' => $this->label, + 'operation_types' => $this->operationTypes, + 'provider_requirement_keys' => $this->providerRequirementKeys, + 'default_reason_code' => $this->defaultReasonCode, + ]; + } +} diff --git a/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php new file mode 100644 index 00000000..81d2c0f5 --- /dev/null +++ b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php @@ -0,0 +1,456 @@ +|null $requiredPermissionsViewModel + */ + public function evaluate( + ManagedEnvironment $tenant, + ?ProviderConnection $connection, + string $capabilityKey, + ?array $requiredPermissionsViewModel = null, + ): ProviderCapabilityResult { + $definition = $this->registry->get($capabilityKey); + $requiredPermissionsViewModel ??= $this->requiredPermissionsViewModel($tenant); + + return $this->evaluateDefinition($tenant, $connection, $definition, $requiredPermissionsViewModel); + } + + /** + * @return array + */ + public function evaluateForConnection(ProviderConnection $connection, ?ManagedEnvironment $tenant = null): array + { + $tenant ??= $connection->tenant instanceof ManagedEnvironment ? $connection->tenant : null; + + if (! $tenant instanceof ManagedEnvironment && is_numeric($connection->managed_environment_id)) { + $tenant = ManagedEnvironment::query()->whereKey((int) $connection->managed_environment_id)->first(); + } + + if (! $tenant instanceof ManagedEnvironment) { + return array_map( + fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => new ProviderCapabilityResult( + key: $definition->key, + label: $definition->label, + status: ProviderCapabilityStatus::Unknown, + reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, + providerRequirementKeys: $definition->providerRequirementKeys, + primaryMessage: 'Provider capability could not be evaluated because the tenant scope is unavailable.', + providerHint: 'Open the provider connection from a tenant-scoped workspace context.', + ), + array_values($this->registry->all()), + ); + } + + $requiredPermissionsViewModel = $this->requiredPermissionsViewModel($tenant); + + return array_map( + fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => $this->evaluateDefinition( + tenant: $tenant, + connection: $connection, + definition: $definition, + requiredPermissionsViewModel: $requiredPermissionsViewModel, + ), + array_values($this->registry->all()), + ); + } + + /** + * @return array + */ + public function evaluateForOperation( + ManagedEnvironment $tenant, + ?ProviderConnection $connection, + string $operationType, + ): array { + $definitions = $this->registry->forOperationType($operationType); + $requiredPermissionsViewModel = $this->requiredPermissionsViewModel($tenant); + + return array_map( + fn (ProviderCapabilityDefinition $definition): ProviderCapabilityResult => $this->evaluateDefinition( + tenant: $tenant, + connection: $connection, + definition: $definition, + requiredPermissionsViewModel: $requiredPermissionsViewModel, + ), + $definitions, + ); + } + + /** + * @param array $results + */ + public function primaryBlockingResult(array $results): ?ProviderCapabilityResult + { + $blocking = array_values(array_filter( + $results, + static fn (ProviderCapabilityResult $result): bool => $result->blocksExecution(), + )); + + if ($blocking === []) { + return null; + } + + usort( + $blocking, + static fn (ProviderCapabilityResult $a, ProviderCapabilityResult $b): int => $a->status->priority() <=> $b->status->priority(), + ); + + return $blocking[0]; + } + + /** + * @param array $results + */ + public function primaryDisplayResult(array $results): ?ProviderCapabilityResult + { + if ($results === []) { + return null; + } + + usort( + $results, + static fn (ProviderCapabilityResult $a, ProviderCapabilityResult $b): int => $a->status->priority() <=> $b->status->priority(), + ); + + return $results[0]; + } + + /** + * @param array $requiredPermissionsViewModel + */ + private function evaluateDefinition( + ManagedEnvironment $tenant, + ?ProviderConnection $connection, + ProviderCapabilityDefinition $definition, + array $requiredPermissionsViewModel, + ): ProviderCapabilityResult { + $base = [ + 'key' => $definition->key, + 'label' => $definition->label, + 'providerRequirementKeys' => $definition->providerRequirementKeys, + 'lastCheckedAt' => $this->lastCheckedAt($requiredPermissionsViewModel), + ]; + + if (! $connection instanceof ProviderConnection) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Unknown, + reasonCode: ProviderReasonCodes::ProviderConnectionMissing, + primaryMessage: "{$definition->label} capability cannot be evaluated without a provider connection.", + providerHint: 'Select or create a provider connection for this tenant.', + ); + } + + $provider = trim((string) $connection->provider); + if ($provider !== 'microsoft') { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::NotApplicable, + primaryMessage: "{$definition->label} capability is not mapped for provider {$provider}.", + providerHint: 'This release only has explicit Microsoft provider capability mappings.', + ); + } + + if (! $this->connectionMatchesTenant($tenant, $connection)) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Blocked, + reasonCode: ProviderReasonCodes::TenantTargetMismatch, + primaryMessage: "{$definition->label} capability is blocked by a tenant target mismatch.", + providerHint: 'Review the provider connection target scope before starting provider operations.', + ); + } + + if (! (bool) $connection->is_enabled) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Blocked, + reasonCode: ProviderReasonCodes::ProviderConnectionInvalid, + primaryMessage: "{$definition->label} capability is blocked because the provider connection is disabled.", + providerHint: 'Enable the provider connection after confirming it should be active.', + ); + } + + if ($connection->requiresMigrationReview()) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Blocked, + reasonCode: ProviderReasonCodes::ProviderConnectionReviewRequired, + primaryMessage: "{$definition->label} capability is blocked until connection classification is reviewed.", + providerHint: 'Review the provider connection classification before retrying.', + ); + } + + $consentStatus = $this->stateValue($connection->consent_status); + if ($consentStatus !== ProviderConsentStatus::Granted->value) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Missing, + reasonCode: $this->consentReasonCode($consentStatus), + primaryMessage: "{$definition->label} capability is missing admin consent.", + providerHint: 'Grant admin consent, then rerun provider verification.', + nextStepLabel: 'Grant admin consent', + nextStepUrl: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant), + ); + } + + $verificationStatus = $this->stateValue($connection->verification_status); + if (in_array($verificationStatus, [ + ProviderVerificationStatus::Blocked->value, + ProviderVerificationStatus::Error->value, + ], true)) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Blocked, + reasonCode: ProviderReasonCodes::ProviderPermissionRefreshFailed, + primaryMessage: "{$definition->label} capability is blocked by the latest provider verification status.", + providerHint: 'Open Required Permissions or rerun verification before retrying.', + nextStepLabel: 'Open Required Permissions', + nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant), + ); + } + + if ($definition->providerRequirementKeys === ['permissions.admin_consent']) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Supported, + primaryMessage: "{$definition->label} capability is supported for this provider connection.", + evidenceCounts: ['requirements' => 1, 'missing' => 0, 'errors' => 0], + ); + } + + return $this->evaluatePermissionRequirements($tenant, $definition, $requiredPermissionsViewModel, $base); + } + + /** + * @param array $requiredPermissionsViewModel + * @param array $base + */ + private function evaluatePermissionRequirements( + ManagedEnvironment $tenant, + ProviderCapabilityDefinition $definition, + array $requiredPermissionsViewModel, + array $base, + ): ProviderCapabilityResult { + $permissions = is_array($requiredPermissionsViewModel['permissions'] ?? null) + ? $requiredPermissionsViewModel['permissions'] + : []; + $freshness = is_array(data_get($requiredPermissionsViewModel, 'overview.freshness')) + ? data_get($requiredPermissionsViewModel, 'overview.freshness') + : []; + + $requirementRows = []; + + foreach ($definition->providerRequirementKeys as $requirementKey) { + if ($requirementKey === 'permissions.admin_consent') { + continue; + } + + $requirementRows[$requirementKey] = TenantPermissionCheckClusters::rowsForRequirementKey($permissions, $requirementKey); + } + + $allRows = array_values(array_merge(...array_values($requirementRows ?: [[]]))); + + if ($allRows === []) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::NotApplicable, + primaryMessage: "{$definition->label} capability has no mapped permission rows for this tenant.", + providerHint: 'No configured permission requirement currently maps to this provider capability.', + evidenceCounts: ['requirements' => 0, 'missing' => 0, 'errors' => 0], + ); + } + + $missingRows = array_values(array_filter( + $allRows, + static fn (array $row): bool => ($row['status'] ?? null) === 'missing', + )); + $errorRows = array_values(array_filter( + $allRows, + static fn (array $row): bool => ($row['status'] ?? null) === 'error', + )); + $missingRequirementKeys = $this->missingRequirementKeys($requirementRows); + + if ($errorRows !== []) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Unknown, + reasonCode: ProviderReasonCodes::ProviderPermissionRefreshFailed, + missingRequirementKeys: $missingRequirementKeys, + primaryMessage: "{$definition->label} capability needs a fresh permission check.", + providerHint: 'Stored permission evidence has errors. Rerun provider verification.', + evidenceCounts: [ + 'requirements' => count($allRows), + 'missing' => count($missingRows), + 'errors' => count($errorRows), + ], + nextStepLabel: 'Open Required Permissions', + nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant), + ); + } + + if ($missingRows !== []) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Missing, + reasonCode: $this->permissionMissingReasonCode($definition, $missingRequirementKeys), + missingRequirementKeys: $missingRequirementKeys, + primaryMessage: "{$definition->label} capability is missing required provider permissions.", + providerHint: 'Open Required Permissions to review capability-grouped remediation.', + evidenceCounts: [ + 'requirements' => count($allRows), + 'missing' => count($missingRows), + 'errors' => 0, + ], + nextStepLabel: 'Open Required Permissions', + nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant), + ); + } + + if ((bool) ($freshness['is_stale'] ?? true)) { + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Unknown, + reasonCode: ProviderReasonCodes::ProviderPermissionRefreshFailed, + primaryMessage: "{$definition->label} capability is based on stale permission evidence.", + providerHint: 'Rerun provider verification before starting this provider operation.', + evidenceCounts: [ + 'requirements' => count($allRows), + 'missing' => 0, + 'errors' => 0, + ], + nextStepLabel: 'Open Required Permissions', + nextStepUrl: RequiredPermissionsLinks::requiredPermissions($tenant), + ); + } + + return new ProviderCapabilityResult( + ...$base, + status: ProviderCapabilityStatus::Supported, + primaryMessage: "{$definition->label} capability is supported for this provider connection.", + evidenceCounts: [ + 'requirements' => count($allRows), + 'missing' => 0, + 'errors' => 0, + ], + ); + } + + /** + * @return array + */ + private function requiredPermissionsViewModel(ManagedEnvironment $tenant): array + { + return $this->requiredPermissionsViewModelBuilder->build($tenant, [ + 'status' => 'all', + 'type' => 'all', + 'features' => [], + 'search' => '', + ]); + } + + /** + * @param array>> $requirementRows + * @return array + */ + private function missingRequirementKeys(array $requirementRows): array + { + $missingKeys = []; + + foreach ($requirementRows as $requirementKey => $rows) { + foreach ($rows as $row) { + if (in_array(($row['status'] ?? null), ['missing', 'error'], true)) { + $missingKeys[] = $requirementKey; + break; + } + } + } + + return array_values(array_unique($missingKeys)); + } + + /** + * @param array $missingRequirementKeys + */ + private function permissionMissingReasonCode( + ProviderCapabilityDefinition $definition, + array $missingRequirementKeys, + ): string { + if ($definition->key === 'restore_execute' || in_array('permissions.intune_rbac_assignments', $missingRequirementKeys, true)) { + return ProviderReasonCodes::IntuneRbacPermissionMissing; + } + + return $definition->defaultReasonCode; + } + + private function consentReasonCode(string $consentStatus): string + { + return match ($consentStatus) { + ProviderConsentStatus::Failed->value => ProviderReasonCodes::ProviderConsentFailed, + ProviderConsentStatus::Revoked->value => ProviderReasonCodes::ProviderConsentRevoked, + default => ProviderReasonCodes::ProviderConsentMissing, + }; + } + + private function connectionMatchesTenant(ManagedEnvironment $tenant, ProviderConnection $connection): bool + { + if (is_numeric($connection->managed_environment_id) && (int) $connection->managed_environment_id !== (int) $tenant->getKey()) { + return false; + } + + $connectionTenantId = trim((string) $connection->entra_tenant_id); + $tenantDirectoryId = trim((string) $tenant->managed_environment_id); + + return $connectionTenantId === '' || $tenantDirectoryId === '' || $connectionTenantId === $tenantDirectoryId; + } + + /** + * @param array $requiredPermissionsViewModel + */ + private function lastCheckedAt(array $requiredPermissionsViewModel): ?string + { + $lastCheckedAt = data_get($requiredPermissionsViewModel, 'overview.freshness.last_refreshed_at'); + + return is_string($lastCheckedAt) && trim($lastCheckedAt) !== '' + ? trim($lastCheckedAt) + : null; + } + + private function stateValue(mixed $state): string + { + if ($state instanceof ProviderConsentStatus || $state instanceof ProviderVerificationStatus) { + return $state->value; + } + + if (is_string($state) && trim($state) !== '') { + return trim($state); + } + + if ($state === null) { + return ''; + } + + throw new InvalidArgumentException('Provider capability evaluator received an unsupported provider state value.'); + } +} diff --git a/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php new file mode 100644 index 00000000..08c4df56 --- /dev/null +++ b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php @@ -0,0 +1,114 @@ + + */ + public function definitions(): array + { + return [ + 'provider_connection_check' => new ProviderCapabilityDefinition( + key: 'provider_connection_check', + label: 'Provider connection check', + operationTypes: ['provider.connection.check'], + providerRequirementKeys: ['permissions.admin_consent'], + defaultReasonCode: ProviderReasonCodes::ProviderConsentMissing, + ), + 'inventory_read' => new ProviderCapabilityDefinition( + key: 'inventory_read', + label: 'Inventory read', + operationTypes: ['inventory.sync'], + providerRequirementKeys: ['permissions.intune_configuration', 'permissions.intune_apps'], + defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing, + ), + 'configuration_read' => new ProviderCapabilityDefinition( + key: 'configuration_read', + label: 'Configuration read', + operationTypes: ['compliance.snapshot'], + providerRequirementKeys: ['permissions.intune_configuration'], + defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing, + ), + 'restore_execute' => new ProviderCapabilityDefinition( + key: 'restore_execute', + label: 'Restore execute', + operationTypes: ['restore.execute'], + providerRequirementKeys: ['permissions.intune_configuration', 'permissions.intune_rbac_assignments'], + defaultReasonCode: ProviderReasonCodes::IntuneRbacPermissionMissing, + ), + 'directory_groups_read' => new ProviderCapabilityDefinition( + key: 'directory_groups_read', + label: 'Directory groups read', + operationTypes: ['directory.groups.sync'], + providerRequirementKeys: ['permissions.directory_groups'], + defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing, + ), + 'directory_role_definitions_read' => new ProviderCapabilityDefinition( + key: 'directory_role_definitions_read', + label: 'Directory role definitions read', + operationTypes: ['directory.role_definitions.sync'], + providerRequirementKeys: ['provider.directory_role_definitions', 'permissions.admin_consent'], + defaultReasonCode: ProviderReasonCodes::ProviderPermissionMissing, + ), + ]; + } + + /** + * @return array + */ + public function all(): array + { + return $this->definitions(); + } + + /** + * @return array + */ + public function keys(): array + { + return array_keys($this->definitions()); + } + + public function get(string $key): ProviderCapabilityDefinition + { + $key = trim($key); + $definition = $this->definitions()[$key] ?? null; + + if (! $definition instanceof ProviderCapabilityDefinition) { + throw new InvalidArgumentException("Unknown provider capability key: {$key}"); + } + + return $definition; + } + + /** + * @return array + */ + public function forOperationType(string $operationType): array + { + $operationType = trim($operationType); + + return array_values(array_filter( + $this->definitions(), + static fn (ProviderCapabilityDefinition $definition): bool => in_array($operationType, $definition->operationTypes, true), + )); + } + + /** + * @return array + */ + public function keysForOperationType(string $operationType): array + { + return array_map( + static fn (ProviderCapabilityDefinition $definition): string => $definition->key, + $this->forOperationType($operationType), + ); + } +} diff --git a/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityResult.php b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityResult.php new file mode 100644 index 00000000..0029e57d --- /dev/null +++ b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityResult.php @@ -0,0 +1,70 @@ + $providerRequirementKeys + * @param array $missingRequirementKeys + * @param array $evidenceCounts + */ + public function __construct( + public string $key, + public string $label, + public ProviderCapabilityStatus $status, + public ?string $reasonCode = null, + public array $providerRequirementKeys = [], + public array $missingRequirementKeys = [], + public ?string $lastCheckedAt = null, + public ?string $primaryMessage = null, + public ?string $providerHint = null, + public array $evidenceCounts = [], + public ?string $nextStepLabel = null, + public ?string $nextStepUrl = null, + ) {} + + public function blocksExecution(): bool + { + return $this->status->blocksExecution(); + } + + /** + * @return array{ + * provider_capability_key: string, + * label: string, + * status: string, + * reason_code: ?string, + * provider_requirement_keys: array, + * missing_requirement_keys: array, + * last_checked_at: ?string, + * primary_message: ?string, + * provider_hint: ?string, + * evidence_counts: array, + * next_step: array{label: ?string, url: ?string}, + * blocks_execution: bool + * } + */ + public function toArray(): array + { + return [ + 'provider_capability_key' => $this->key, + 'label' => $this->label, + 'status' => $this->status->value, + 'reason_code' => $this->reasonCode, + 'provider_requirement_keys' => $this->providerRequirementKeys, + 'missing_requirement_keys' => $this->missingRequirementKeys, + 'last_checked_at' => $this->lastCheckedAt, + 'primary_message' => $this->primaryMessage, + 'provider_hint' => $this->providerHint, + 'evidence_counts' => $this->evidenceCounts, + 'next_step' => [ + 'label' => $this->nextStepLabel, + 'url' => $this->nextStepUrl, + ], + 'blocks_execution' => $this->blocksExecution(), + ]; + } +} diff --git a/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityStatus.php b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityStatus.php new file mode 100644 index 00000000..bab8d8a4 --- /dev/null +++ b/apps/platform/app/Support/Providers/Capabilities/ProviderCapabilityStatus.php @@ -0,0 +1,33 @@ + false, + self::Missing, self::Blocked, self::Unknown => true, + }; + } + + public function priority(): int + { + return match ($this) { + self::Blocked => 0, + self::Missing => 1, + self::Unknown => 2, + self::Supported => 3, + self::NotApplicable => 4, + }; + } +} diff --git a/apps/platform/app/Support/Providers/ProviderReasonTranslator.php b/apps/platform/app/Support/Providers/ProviderReasonTranslator.php index 61cddb13..f8e7060f 100644 --- a/apps/platform/app/Support/Providers/ProviderReasonTranslator.php +++ b/apps/platform/app/Support/Providers/ProviderReasonTranslator.php @@ -42,6 +42,7 @@ public function translate(string $reasonCode, string $surface = 'detail', array : ProviderReasonCodes::UnknownError; $tenant = $context['tenant'] ?? null; $connection = $context['connection'] ?? null; + $capabilityContext = $this->capabilityContext($context); if (! $tenant instanceof ManagedEnvironment) { $nextSteps = $this->fallbackNextSteps($normalizedCode); @@ -157,29 +158,45 @@ public function translate(string $reasonCode, string $surface = 'detail', array ), ProviderReasonCodes::ProviderPermissionMissing => $this->envelope( reasonCode: $normalizedCode, - operatorLabel: 'Permissions missing', - shortExplanation: 'The provider app is missing required Microsoft Graph permissions.', + operatorLabel: is_array($capabilityContext) + ? sprintf('%s capability missing', (string) $capabilityContext['label']) + : 'Permissions missing', + shortExplanation: is_array($capabilityContext) + ? sprintf('%s capability is missing required provider permissions.', (string) $capabilityContext['label']) + : 'The provider app is missing required Microsoft Graph permissions.', actionability: 'prerequisite_missing', nextSteps: $nextSteps, ), ProviderReasonCodes::ProviderPermissionDenied => $this->envelope( reasonCode: $normalizedCode, - operatorLabel: 'Permission denied', - shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.', + operatorLabel: is_array($capabilityContext) + ? sprintf('%s capability denied', (string) $capabilityContext['label']) + : 'Permission denied', + shortExplanation: is_array($capabilityContext) + ? sprintf('%s capability could not be used with the current provider permission evidence.', (string) $capabilityContext['label']) + : 'Microsoft Graph denied the requested permission for this provider connection.', actionability: 'permanent_configuration', nextSteps: $nextSteps, ), ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope( reasonCode: $normalizedCode, - operatorLabel: 'Permission refresh failed', - shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.', + operatorLabel: is_array($capabilityContext) + ? sprintf('%s capability needs refresh', (string) $capabilityContext['label']) + : 'Permission refresh failed', + shortExplanation: is_array($capabilityContext) + ? sprintf('%s capability needs refreshed provider permission evidence before this operation can continue.', (string) $capabilityContext['label']) + : 'TenantPilot could not refresh the provider permission snapshot.', actionability: 'retryable_transient', nextSteps: $nextSteps, ), ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope( reasonCode: $normalizedCode, - operatorLabel: 'Intune RBAC permission missing', - shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.', + operatorLabel: is_array($capabilityContext) + ? sprintf('%s capability missing', (string) $capabilityContext['label']) + : 'Intune RBAC permission missing', + shortExplanation: is_array($capabilityContext) + ? sprintf('%s capability is missing a required provider RBAC prerequisite.', (string) $capabilityContext['label']) + : 'The provider app lacks the Intune RBAC permission needed for this workflow.', actionability: 'prerequisite_missing', nextSteps: $nextSteps, ), @@ -259,6 +276,49 @@ private function envelope( ); } + /** + * @param array $context + * @return array{label:string,status:string}|null + */ + private function capabilityContext(array $context): ?array + { + $candidate = $context['provider_capability'] ?? null; + + if (! is_array($candidate)) { + $capabilities = $context['provider_capabilities'] ?? null; + $capabilities = is_array($capabilities) ? $capabilities : []; + + foreach ($capabilities as $capability) { + if (! is_array($capability)) { + continue; + } + + $status = is_string($capability['status'] ?? null) ? trim((string) $capability['status']) : ''; + + if (in_array($status, ['blocked', 'missing', 'unknown'], true)) { + $candidate = $capability; + break; + } + } + } + + if (! is_array($candidate)) { + return null; + } + + $label = is_string($candidate['label'] ?? null) ? trim((string) $candidate['label']) : ''; + $status = is_string($candidate['status'] ?? null) ? trim((string) $candidate['status']) : ''; + + if ($label === '' || $status === '') { + return null; + } + + return [ + 'label' => $label, + 'status' => $status, + ]; + } + /** * @return array */ diff --git a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php index 4db984d3..f5ca87b1 100644 --- a/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php +++ b/apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php @@ -5,6 +5,8 @@ use App\Models\ProviderConnection; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; +use App\Support\Providers\Capabilities\ProviderCapabilityEvaluator; +use App\Support\Providers\Capabilities\ProviderCapabilityResult; use App\Support\Providers\ProviderConsentStatus; use App\Support\Providers\ProviderVerificationStatus; @@ -21,6 +23,8 @@ public function __construct( public readonly string $readinessSummary, public readonly array $contextualIdentityDetails = [], public readonly bool $isEnabled = true, + public readonly array $providerCapabilities = [], + public readonly ?array $primaryProviderCapability = null, ) {} public static function forConnection(ProviderConnection $connection): self @@ -30,6 +34,8 @@ public static function forConnection(ProviderConnection $connection): self $targetScope = $normalizer->descriptorForConnection($connection); $consentState = self::stateValue($connection->consent_status); $verificationState = self::stateValue($connection->verification_status); + $providerCapabilities = self::providerCapabilitiesForConnection($connection); + $primaryProviderCapability = self::primaryProviderCapability($providerCapabilities); return new self( provider: trim((string) $connection->provider), @@ -43,6 +49,8 @@ public static function forConnection(ProviderConnection $connection): self ), contextualIdentityDetails: $normalizer->contextualIdentityDetailsForConnection($connection), isEnabled: (bool) $connection->is_enabled, + providerCapabilities: $providerCapabilities, + primaryProviderCapability: $primaryProviderCapability, ); } @@ -62,6 +70,19 @@ public function contextualIdentityLine(): ?string ->implode("\n"); } + public function providerCapabilitySummary(): string + { + if (! is_array($this->primaryProviderCapability)) { + return 'Provider capabilities not evaluated'; + } + + $label = (string) ($this->primaryProviderCapability['label'] ?? 'Provider capability'); + $status = (string) ($this->primaryProviderCapability['status'] ?? 'unknown'); + $statusLabel = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $status)->label; + + return "{$label}: {$statusLabel}"; + } + /** * @return array{ * provider: string, @@ -72,7 +93,10 @@ public function contextualIdentityLine(): ?string * target_scope_summary: string, * provider_context: array{provider: string, details: list>}, * contextual_identity_line: ?string, - * is_enabled: bool + * is_enabled: bool, + * provider_capabilities: array>, + * primary_provider_capability: ?array, + * provider_capability_summary: string * } */ public function toArray(): array @@ -87,6 +111,9 @@ public function toArray(): array 'provider_context' => $this->providerContext(), 'contextual_identity_line' => $this->contextualIdentityLine(), 'is_enabled' => $this->isEnabled, + 'provider_capabilities' => $this->providerCapabilities, + 'primary_provider_capability' => $this->primaryProviderCapability, + 'provider_capability_summary' => $this->providerCapabilitySummary(), ]; } @@ -135,4 +162,57 @@ private static function readinessSummary(bool $isEnabled, string $consentState, default => 'Verification not run', }; } + + /** + * @return array> + */ + private static function providerCapabilitiesForConnection(ProviderConnection $connection): array + { + try { + /** @var ProviderCapabilityEvaluator $evaluator */ + $evaluator = app(ProviderCapabilityEvaluator::class); + + return array_map( + static fn (ProviderCapabilityResult $result): array => $result->toArray(), + $evaluator->evaluateForConnection($connection), + ); + } catch (\Throwable) { + return []; + } + } + + /** + * @param array> $capabilities + * @return array|null + */ + private static function primaryProviderCapability(array $capabilities): ?array + { + if ($capabilities === []) { + return null; + } + + usort($capabilities, static function (array $a, array $b): int { + $aStatus = (string) ($a['status'] ?? 'unknown'); + $bStatus = (string) ($b['status'] ?? 'unknown'); + + $aPriority = match ($aStatus) { + 'blocked' => 0, + 'missing' => 1, + 'unknown' => 2, + 'supported' => 3, + default => 4, + }; + $bPriority = match ($bStatus) { + 'blocked' => 0, + 'missing' => 1, + 'unknown' => 2, + 'supported' => 3, + default => 4, + }; + + return $aPriority <=> $bPriority; + }); + + return $capabilities[0]; + } } diff --git a/apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php b/apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php index 23eea9a8..2fd78aa0 100644 --- a/apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php +++ b/apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php @@ -59,7 +59,7 @@ public static function buildChecks(ManagedEnvironment $tenant, array $permission $key = (string) ($definition['key'] ?? 'unknown'); $title = (string) ($definition['title'] ?? 'Check'); - $clusterRows = array_values(array_filter($rows, fn (array $row): bool => self::matches($definition, $row))); + $clusterRows = array_values(array_filter($rows, fn (array $row): bool => self::matchesDefinition($definition, $row))); $checks[] = self::buildCheck( tenant: $tenant, @@ -77,9 +77,9 @@ public static function buildChecks(ManagedEnvironment $tenant, array $permission } /** - * @return array,keys?:array}> + * @return array,keys?:array,type?:string}> */ - private static function definitions(): array + public static function definitions(): array { return [ [ @@ -122,6 +122,14 @@ private static function definitions(): array 'DeviceManagementRBAC.', ], ], + [ + 'key' => 'provider.directory_role_definitions', + 'title' => 'Directory role definitions read access', + 'mode' => 'keys', + 'keys' => [ + 'RoleManagement.Read.Directory', + ], + ], [ 'key' => 'permissions.scripts_remediations', 'title' => 'Scripts/remediations access', @@ -137,7 +145,7 @@ private static function definitions(): array * @param array{mode:string,prefixes?:array,keys?:array,type?:string} $definition * @param TenantPermissionRow $row */ - private static function matches(array $definition, array $row): bool + public static function matchesDefinition(array $definition, array $row): bool { $mode = (string) ($definition['mode'] ?? ''); $key = (string) ($row['key'] ?? ''); @@ -171,6 +179,54 @@ private static function matches(array $definition, array $row): bool return false; } + /** + * @param array> $permissions + * @return array + */ + public static function rowsForRequirementKey(array $permissions, string $requirementKey): array + { + $definition = self::definitionForKey($requirementKey); + + if (! is_array($definition)) { + return []; + } + + return collect($permissions) + ->filter(fn (mixed $row): bool => is_array($row)) + ->map(fn (array $row): array => self::normalizePermissionRow($row)) + ->filter(fn (array $row): bool => self::matchesDefinition($definition, $row)) + ->values() + ->all(); + } + + /** + * @param TenantPermissionRow $row + * @return array + */ + public static function requirementKeysForPermissionRow(array $row): array + { + return collect(self::definitions()) + ->filter(fn (array $definition): bool => self::matchesDefinition($definition, self::normalizePermissionRow($row))) + ->map(fn (array $definition): string => (string) ($definition['key'] ?? '')) + ->filter() + ->values() + ->all(); + } + + /** + * @return array{key:string,title:string,mode:string,prefixes?:array,keys?:array,type?:string}|null + */ + public static function definitionForKey(string $requirementKey): ?array + { + foreach (self::definitions() as $definition) { + if (($definition['key'] ?? null) === $requirementKey) { + return $definition; + } + } + + return null; + } + /** * @param array $clusterRows * @return array diff --git a/apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php b/apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php index 668ba69e..17bcdebb 100644 --- a/apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php +++ b/apps/platform/app/Support/Verification/VerificationAssistViewModelBuilder.php @@ -74,6 +74,10 @@ public function build( : []; $counts = $this->normalizeCounts(is_array($overview['counts'] ?? null) ? $overview['counts'] : []); $freshness = $this->normalizeFreshness(is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []); + $capabilityGroups = is_array($overview['capability_groups'] ?? null) ? $overview['capability_groups'] : []; + $primaryCapabilityGroup = is_array($overview['primary_capability_group'] ?? null) + ? $overview['primary_capability_group'] + : null; $rows = $this->attentionRows(is_array($requiredPermissionsViewModel['permissions'] ?? null) ? $requiredPermissionsViewModel['permissions'] : []); @@ -110,6 +114,8 @@ public function build( 'overall' => $this->normalizeOverviewOverall($overview['overall'] ?? null), 'counts' => $counts, 'freshness' => $freshness, + 'capability_groups' => $capabilityGroups, + 'primary_capability_group' => $primaryCapabilityGroup, ], 'missing_permissions' => $partitionedRows, 'copy' => [ diff --git a/apps/platform/config/provider_boundaries.php b/apps/platform/config/provider_boundaries.php index 9e0e71f8..6b9f9e04 100644 --- a/apps/platform/config/provider_boundaries.php +++ b/apps/platform/config/provider_boundaries.php @@ -93,6 +93,29 @@ ], 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE, ], + 'provider.capability_registry' => [ + 'owner' => ProviderBoundaryOwner::PlatformCore->value, + 'description' => 'Platform-core derived provider capability catalog that maps operation readiness to existing provider permission, consent, and connection evidence without adding persistence.', + 'implementation_paths' => [ + 'app/Support/Providers/Capabilities/ProviderCapabilityRegistry.php', + 'app/Support/Providers/Capabilities/ProviderCapabilityEvaluator.php', + 'app/Support/Verification/TenantPermissionCheckClusters.php', + ], + 'neutral_terms' => [ + 'provider capability', + 'provider requirement key', + 'operation type', + 'capability status', + 'required provider capability', + ], + 'retained_provider_semantics' => [ + 'microsoft', + 'Microsoft Graph permission keys', + 'admin consent', + 'RoleManagement.Read.Directory', + ], + 'follow_up_action' => ProviderBoundarySeam::FOLLOW_UP_DOCUMENT_IN_FEATURE, + ], 'provider.operation_start_gate' => [ 'owner' => ProviderBoundaryOwner::PlatformCore->value, 'description' => 'Platform-core operation start orchestration that consumes explicit provider bindings and records neutral target-scope context with provider-specific follow-up detail nested separately.', diff --git a/apps/platform/resources/views/filament/actions/verification-required-permissions-assist.blade.php b/apps/platform/resources/views/filament/actions/verification-required-permissions-assist.blade.php index 752d9a90..51d593f6 100644 --- a/apps/platform/resources/views/filament/actions/verification-required-permissions-assist.blade.php +++ b/apps/platform/resources/views/filament/actions/verification-required-permissions-assist.blade.php @@ -10,6 +10,8 @@ $overview = is_array($assist['overview'] ?? null) ? $assist['overview'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []; + $capabilityGroups = is_array($overview['capability_groups'] ?? null) ? $overview['capability_groups'] : []; + $primaryCapabilityGroup = is_array($overview['primary_capability_group'] ?? null) ? $overview['primary_capability_group'] : null; $missingPermissions = is_array($assist['missing_permissions'] ?? null) ? $assist['missing_permissions'] : []; $applicationRows = is_array($missingPermissions['application'] ?? null) ? $missingPermissions['application'] : []; $delegatedRows = is_array($missingPermissions['delegated'] ?? null) ? $missingPermissions['delegated'] : []; @@ -146,6 +148,62 @@ @endif + @if ($capabilityGroups !== []) +
+
+
+
Provider capabilities
+
+ Capability-level remediation before raw provider permission names. +
+
+ + @if ($primaryCapabilityGroup) + @php + $primaryCapabilityStatus = (string) ($primaryCapabilityGroup['status'] ?? 'unknown'); + $primaryCapabilitySpec = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $primaryCapabilityStatus); + @endphp + + + {{ (string) ($primaryCapabilityGroup['label'] ?? 'Provider capability') }}: {{ $primaryCapabilitySpec->label }} + + @endif +
+ +
+ @foreach ($capabilityGroups as $capabilityGroup) + @php + if (! is_array($capabilityGroup)) { + continue; + } + + $capabilityLabel = (string) ($capabilityGroup['label'] ?? 'Provider capability'); + $capabilityStatus = (string) ($capabilityGroup['status'] ?? 'unknown'); + $capabilitySpec = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $capabilityStatus); + $capabilityMessage = (string) ($capabilityGroup['message'] ?? ''); + @endphp + +
+
+
+
+ {{ $capabilityLabel }} +
+
+ {{ $capabilityMessage }} +
+
+ + + {{ $capabilitySpec->label }} + +
+
+ @endforeach +
+
+ @endif +
Recovery actions
diff --git a/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php b/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php index 5f4342e0..482e532f 100644 --- a/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php +++ b/apps/platform/resources/views/filament/pages/tenant-required-permissions.blade.php @@ -10,6 +10,8 @@ $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : []; + $capabilityGroups = is_array($overview['capability_groups'] ?? null) ? $overview['capability_groups'] : []; + $primaryCapabilityGroup = is_array($overview['primary_capability_group'] ?? null) ? $overview['primary_capability_group'] : null; $freshness = is_array($overview['freshness'] ?? null) ? $overview['freshness'] : []; $filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : []; @@ -144,6 +146,69 @@
@endif + @if ($capabilityGroups !== []) +
+
+
+
Provider capabilities
+
+ Capability-first view of the provider prerequisites used by operation start gates. +
+
+ + @if ($primaryCapabilityGroup) + @php + $primaryStatus = (string) ($primaryCapabilityGroup['status'] ?? 'unknown'); + $primarySpec = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $primaryStatus); + @endphp + + + {{ (string) ($primaryCapabilityGroup['label'] ?? 'Provider capability') }}: {{ $primarySpec->label }} + + @endif +
+ +
+ @foreach ($capabilityGroups as $capabilityGroup) + @php + if (! is_array($capabilityGroup)) { + continue; + } + + $capabilityLabel = (string) ($capabilityGroup['label'] ?? 'Provider capability'); + $capabilityStatus = (string) ($capabilityGroup['status'] ?? 'unknown'); + $capabilitySpec = BadgeRenderer::spec(BadgeDomain::ProviderCapabilityStatus, $capabilityStatus); + $message = (string) ($capabilityGroup['message'] ?? ''); + $capabilityCounts = is_array($capabilityGroup['evidence_counts'] ?? null) ? $capabilityGroup['evidence_counts'] : []; + $missing = (int) ($capabilityCounts['missing'] ?? 0); + $errors = (int) ($capabilityCounts['errors'] ?? 0); + @endphp + +
+
+
+
+ {{ $capabilityLabel }} +
+
+ {{ $message }} +
+
+ + + {{ $capabilitySpec->label }} + +
+ +
+ {{ $missing }} missing, {{ $errors }} error(s) +
+
+ @endforeach +
+
+ @endif +
Guidance
diff --git a/apps/platform/tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php b/apps/platform/tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php new file mode 100644 index 00000000..d62064cb --- /dev/null +++ b/apps/platform/tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php @@ -0,0 +1,30 @@ +browser()->timeout(20_000); + +it('smokes provider capability grouping on the required permissions page', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + + $this->actingAs($user)->withSession([ + WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id, + WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [ + (string) $tenant->workspace_id => (int) $tenant->getKey(), + ], + ]); + + visit(RequiredPermissionsLinks::requiredPermissions($tenant, ['status' => 'all'])) + ->waitForText('Provider capabilities') + ->assertSee('Inventory read') + ->assertSee('Directory role definitions read') + ->assertSee('Technical details') + ->assertNoJavaScriptErrors() + ->assertNoConsoleLogs(); +}); diff --git a/apps/platform/tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php b/apps/platform/tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php new file mode 100644 index 00000000..c6d5492a --- /dev/null +++ b/apps/platform/tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php @@ -0,0 +1,49 @@ +consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'display_name' => 'Spec 283 capability connection', + 'entra_tenant_id' => (string) $tenant->managed_environment_id, + 'provider' => 'microsoft', + 'verification_status' => 'healthy', + ]); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $component = Livewire::actingAs($user)->test(ListProviderConnections::class); + $visibleColumnNames = collect($component->instance()->getTable()->getVisibleColumns()) + ->map(fn ($column): string => $column->getName()) + ->values() + ->all(); + $globalSearchProperty = new ReflectionProperty(ProviderConnectionResource::class, 'isGloballySearchable'); + $globalSearchProperty->setAccessible(true); + + expect($globalSearchProperty->getValue())->toBeFalse() + ->and(array_keys(ProviderConnectionResource::getPages()))->toContain('view', 'edit') + ->and($visibleColumnNames)->toContain('provider_capability'); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('view', [ + 'record' => $connection, + 'managed_environment_id' => $tenant->external_id, + ], panel: 'admin')) + ->assertOk() + ->assertSee('Provider capability') + ->assertSee('Provider connection check'); +}); diff --git a/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php new file mode 100644 index 00000000..83559f45 --- /dev/null +++ b/apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php @@ -0,0 +1,47 @@ +consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => (string) $tenant->managed_environment_id, + 'provider' => 'microsoft', + 'verification_status' => 'blocked', + ]); + + $report = VerificationReportWriter::build('provider.connection.check', [[ + 'key' => 'permissions.directory_groups', + 'title' => 'Directory & group read access', + 'status' => 'fail', + 'severity' => 'critical', + 'blocking' => true, + 'reason_code' => ProviderReasonCodes::ProviderPermissionMissing, + 'message' => 'Missing required provider permissions.', + 'evidence' => [], + 'next_steps' => [], + ]]); + + $assist = app(VerificationAssistViewModelBuilder::class)->build( + tenant: $tenant, + verificationReport: $report, + providerConnection: $connection, + verificationStatus: 'blocked', + ); + + expect(data_get($assist, 'overview.capability_groups'))->toBeArray() + ->and(data_get($assist, 'overview.primary_capability_group.label'))->toBeString() + ->and(collect(data_get($assist, 'overview.capability_groups'))->pluck('label')->all()) + ->toContain('Directory groups read'); +}); diff --git a/apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php b/apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php new file mode 100644 index 00000000..ba43a8b2 --- /dev/null +++ b/apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php @@ -0,0 +1,124 @@ +updateOrCreate( + [ + 'managed_environment_id' => (int) $tenant->getKey(), + 'permission_key' => $permissionKey, + 'workspace_id' => (int) $tenant->workspace_id, + ], + [ + 'status' => in_array($permissionKey, $errorKeys, true) + ? 'error' + : (in_array($permissionKey, $missingKeys, true) ? 'missing' : 'granted'), + 'details' => ['source' => 'spec-283-test'], + 'last_checked_at' => now(), + ], + ); + } + } +} + +it('evaluates supported provider capabilities from stored permission evidence', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => '11111111-1111-1111-1111-111111111111', + ]); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', + 'provider' => 'microsoft', + 'verification_status' => 'healthy', + ]); + + spec283SeedRequirementRows($tenant, ['permissions.intune_configuration', 'permissions.intune_apps']); + + $result = app(ProviderCapabilityEvaluator::class)->evaluate($tenant, $connection, 'inventory_read'); + + expect($result->status)->toBe(ProviderCapabilityStatus::Supported) + ->and($result->missingRequirementKeys)->toBe([]) + ->and($result->blocksExecution())->toBeFalse(); +}); + +it('returns capability-first missing and blocked states', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => '22222222-2222-2222-2222-222222222222', + ]); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => '22222222-2222-2222-2222-222222222222', + 'provider' => 'microsoft', + 'verification_status' => 'healthy', + ]); + + $missing = app(ProviderCapabilityEvaluator::class)->evaluate($tenant, $connection, 'directory_groups_read'); + + expect($missing->status)->toBe(ProviderCapabilityStatus::Missing) + ->and($missing->reasonCode)->toBe(ProviderReasonCodes::ProviderPermissionMissing) + ->and($missing->missingRequirementKeys)->toContain('permissions.directory_groups'); + + $connection->forceFill(['is_enabled' => false])->save(); + + $blocked = app(ProviderCapabilityEvaluator::class)->evaluate($tenant, $connection->fresh(), 'directory_groups_read'); + + expect($blocked->status)->toBe(ProviderCapabilityStatus::Blocked) + ->and($blocked->reasonCode)->toBe(ProviderReasonCodes::ProviderConnectionInvalid); +}); + +it('treats admin consent as the provider connection check prerequisite', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => '33333333-3333-3333-3333-333333333333', + ]); + $connection = ProviderConnection::factory()->withCredential()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => '33333333-3333-3333-3333-333333333333', + 'provider' => 'microsoft', + 'consent_status' => 'required', + ]); + + $result = app(ProviderCapabilityEvaluator::class)->evaluate($tenant, $connection, 'provider_connection_check'); + + expect($result->status)->toBe(ProviderCapabilityStatus::Missing) + ->and($result->reasonCode)->toBe(ProviderReasonCodes::ProviderConsentMissing) + ->and($result->providerRequirementKeys)->toBe(['permissions.admin_consent']); +}); diff --git a/apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php b/apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php new file mode 100644 index 00000000..bc4cccbe --- /dev/null +++ b/apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php @@ -0,0 +1,107 @@ +create([ + 'managed_environment_id' => '44444444-4444-4444-4444-444444444444', + ]); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '44444444-4444-4444-4444-444444444444', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: 'provider.connection.check', + dispatcher: fn (OperationRun $run): null => null, + ); + + $context = $result->run->fresh()->context; + + expect($result->status)->toBe('started') + ->and($context['required_provider_capabilities'] ?? [])->toBe(['provider_connection_check']) + ->and(data_get($context, 'provider_capabilities.0.provider_capability_key'))->toBe('provider_connection_check') + ->and(data_get($context, 'provider_capabilities.0.status'))->toBe('supported'); +}); + +it('preserves active operation dedupe before applying capability blockers', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => '66666666-6666-6666-6666-666666666666', + ]); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '66666666-6666-6666-6666-666666666666', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + $activeRun = OperationRun::factory()->forTenant($tenant)->create([ + 'type' => 'directory.groups.sync', + 'status' => OperationRunStatus::Queued->value, + 'context' => [ + 'provider_connection_id' => (int) $connection->getKey(), + ], + ]); + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: 'directory.groups.sync', + dispatcher: fn (): null => null, + ); + + expect($result->status)->toBe('deduped') + ->and($result->run->is($activeRun))->toBeTrue(); +}); + +it('blocks provider operations when a required provider capability is missing', function (): void { + $tenant = ManagedEnvironment::factory()->create([ + 'managed_environment_id' => '55555555-5555-5555-5555-555555555555', + ]); + $connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => '55555555-5555-5555-5555-555555555555', + ]); + ProviderCredential::factory()->create([ + 'provider_connection_id' => (int) $connection->getKey(), + ]); + + $result = app(ProviderOperationStartGate::class)->start( + tenant: $tenant, + connection: $connection, + operationType: 'directory.groups.sync', + dispatcher: fn (): null => null, + ); + + $context = $result->run->fresh()->context; + + expect($result->status)->toBe('blocked') + ->and($result->run->outcome)->toBe(OperationRunOutcome::Blocked->value) + ->and($context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderPermissionMissing) + ->and($context['required_provider_capabilities'] ?? [])->toBe(['directory_groups_read']) + ->and(data_get($context, 'provider_capability.provider_capability_key'))->toBe('directory_groups_read') + ->and(data_get($context, 'provider_capability.status'))->toBe('missing'); +}); diff --git a/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php new file mode 100644 index 00000000..a143937e --- /dev/null +++ b/apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php @@ -0,0 +1,20 @@ +actingAs($user) + ->get(RequiredPermissionsLinks::requiredPermissions($tenant, ['status' => 'all'])) + ->assertSuccessful() + ->assertSee('Provider capabilities') + ->assertSee('Inventory read') + ->assertSee('Directory role definitions read') + ->assertSeeInOrder(['Provider capabilities', 'Technical details'], false); +}); diff --git a/apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php b/apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php new file mode 100644 index 00000000..66d28ea2 --- /dev/null +++ b/apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php @@ -0,0 +1,38 @@ +consentGranted()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'managed_environment_id' => (int) $tenant->getKey(), + 'entra_tenant_id' => (string) $tenant->managed_environment_id, + 'provider' => 'microsoft', + ]); + + $envelope = app(ProviderReasonTranslator::class)->translate( + ProviderReasonCodes::ProviderPermissionMissing, + context: [ + 'tenant' => $tenant, + 'connection' => $connection, + 'provider_capability' => [ + 'provider_capability_key' => 'directory_groups_read', + 'label' => 'Directory groups read', + 'status' => 'missing', + ], + ], + ); + + expect($envelope?->operatorLabel)->toBe('Directory groups read capability missing') + ->and($envelope?->shortExplanation)->toContain('Directory groups read capability') + ->and($envelope?->firstNextStep()?->label)->toBe('Open Required Permissions'); +}); diff --git a/apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php b/apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php new file mode 100644 index 00000000..84104194 --- /dev/null +++ b/apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php @@ -0,0 +1,37 @@ +keys())->toBe([ + 'provider_connection_check', + 'inventory_read', + 'configuration_read', + 'restore_execute', + 'directory_groups_read', + 'directory_role_definitions_read', + ]) + ->and($registry->keysForOperationType('inventory.sync'))->toBe(['inventory_read']) + ->and($registry->keysForOperationType('directory.role_definitions.sync'))->toBe(['directory_role_definitions_read']) + ->and($registry->get('restore_execute')->providerRequirementKeys)->toBe([ + 'permissions.intune_configuration', + 'permissions.intune_rbac_assignments', + ]) + ->and($registry->get('directory_role_definitions_read')->providerRequirementKeys)->toBe([ + 'provider.directory_role_definitions', + 'permissions.admin_consent', + ]); +}); + +it('registers provider capability status badge semantics', function (): void { + expect(BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, 'supported')->label)->toBe('Supported') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, 'missing')->color)->toBe('warning') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, 'blocked')->color)->toBe('danger') + ->and(BadgeCatalog::spec(BadgeDomain::ProviderCapabilityStatus, 'not_applicable')->label)->toBe('Not applicable'); +}); diff --git a/apps/platform/tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php b/apps/platform/tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php new file mode 100644 index 00000000..1542ad1c --- /dev/null +++ b/apps/platform/tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php @@ -0,0 +1,32 @@ + 'RoleManagement.Read.Directory', + 'type' => 'application', + 'description' => null, + 'features' => ['directory-role-definitions'], + 'status' => 'missing', + 'details' => null, + ]; + + $groupRow = [ + 'key' => 'Group.Read.All', + 'type' => 'application', + 'description' => null, + 'features' => ['directory-groups'], + 'status' => 'missing', + 'details' => null, + ]; + + expect(TenantPermissionCheckClusters::requirementKeysForPermissionRow($roleDefinitionRow)) + ->toContain('provider.directory_role_definitions', 'permissions.admin_consent') + ->and(TenantPermissionCheckClusters::requirementKeysForPermissionRow($groupRow)) + ->toContain('permissions.directory_groups', 'permissions.admin_consent') + ->and(TenantPermissionCheckClusters::rowsForRequirementKey([$roleDefinitionRow, $groupRow], 'provider.directory_role_definitions')) + ->toHaveCount(1); +}); diff --git a/specs/283-provider-capability-registry/checklists/requirements.md b/specs/283-provider-capability-registry/checklists/requirements.md new file mode 100644 index 00000000..9c81452c --- /dev/null +++ b/specs/283-provider-capability-registry/checklists/requirements.md @@ -0,0 +1,65 @@ +# Specification Quality Checklist: Provider Capability Registry + +**Purpose**: Validate package completeness, boundedness, and readiness before implementation +**Created**: 2026-05-08 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] The package stays on reserved slot `283` and does not silently absorb work from Specs `284` through `287`. +- [x] The package explicitly documents that provider capability state is derived and does not introduce a provider-capability table or ledger. +- [x] The package narrows the raw candidate's broader example capability keys to workflows already present in repo truth. +- [x] The package keeps raw Graph permission names and Intune RBAC detail as provider-owned evidence rather than the primary operator vocabulary. +- [x] `plan.md`, `research.md`, `data-model.md`, `quickstart.md`, and the logical contract all describe the same bounded slice. + +## Requirement Completeness + +- [x] No `[NEEDS CLARIFICATION]` markers remain in `spec.md`, `plan.md`, `research.md`, `data-model.md`, or `quickstart.md`. +- [x] Requirements remain testable and bounded to current provider-backed workflows and current operator surfaces. +- [x] The capability-key inventory, status family, and derived-truth posture are explicit across the package. +- [x] The canonical initial capability inventory is pinned identically across `spec.md`, `plan.md`, `data-model.md`, `tasks.md`, and the logical contract. +- [x] Scope boundaries, assumptions, risks, and deferred adjacent candidates remain explicit. + +## Repo Truth Anchoring + +- [x] The package reflects that `ProviderConnectionResource` already exists, stays non-globally-searchable, and already has View and Edit pages. +- [x] The package reflects that `TenantRequiredPermissions` already exists as the canonical diagnostic deep dive. +- [x] The package reflects that `ProviderOperationRegistry` currently maps operation types but not provider application capabilities. +- [x] The package reflects that `ProviderOperationStartGate`, `ProviderReasonTranslator`, onboarding readiness, and required-permissions diagnostics already consume overlapping prerequisite truth. +- [x] The package reflects that `ProviderConnection` already stores consent, verification, and granted-scope inputs, so new capability persistence is unnecessary. + +## Feature Readiness + +- [x] Filament v5 and Livewire v4 expectations remain explicit across the package. +- [x] Provider registration location remains explicit as `apps/platform/bootstrap/providers.php`. +- [x] `ProviderConnectionResource` global-search posture and destructive-action notes remain explicit. +- [x] The unchanged asset strategy remains explicit. +- [x] The implementation prerequisite from Spec `281` remains explicit. +- [x] The test strategy and minimal proving commands are explicit and aligned across artifacts. + +## Artifact Alignment + +- [x] `research.md` records the same bounded derivation decisions reflected in `plan.md`. +- [x] `data-model.md` models the same capability definition, result, grouped diagnostic view, onboarding assist, support explanation, and operation-gate contracts reflected in the spec and plan. +- [x] `quickstart.md` uses the same reviewer flow and proof commands as `spec.md` and `plan.md`. +- [x] `contracts/provider-capability-registry.logical.openapi.yaml` models the same capability summary, diagnostic grouping, onboarding assist, support explanation, and operation-gate contracts described in the plan. +- [x] Canonical proof commands match across `spec.md`, `plan.md`, and `quickstart.md`. + +## Test Governance + +- [x] Planned proof stays bounded to focused unit tests, feature tests, and one browser smoke. +- [x] No new heavy-governance family or broad browser matrix is introduced. +- [x] Workspace, managed-environment, provider-connection, and permission-evidence fixture cost is acknowledged instead of hidden. +- [x] Reviewer handoff includes exact minimal validation commands and concrete stop questions. + +## Notes + +- Reviewed against `.specify/memory/constitution.md`, `docs/product/spec-candidates.md`, `docs/product/roadmap.md`, `specs/281-provider-connection-scope/spec.md`, `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php`, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php`, and `apps/platform/config/provider_boundaries.php` on 2026-05-08. +- No application implementation, test execution, or runtime validation was performed while preparing this package. + +## Review Outcome + +- **Outcome class**: `implementation-ready` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Reason**: The package turns the reserved capability-registry slot into an implementation-ready, repo-grounded slice that standardizes provider workflow capability truth without adding persistence or widening into adjacent cutover work. \ No newline at end of file diff --git a/specs/283-provider-capability-registry/contracts/provider-capability-registry.logical.openapi.yaml b/specs/283-provider-capability-registry/contracts/provider-capability-registry.logical.openapi.yaml new file mode 100644 index 00000000..97b9add8 --- /dev/null +++ b/specs/283-provider-capability-registry/contracts/provider-capability-registry.logical.openapi.yaml @@ -0,0 +1,394 @@ +openapi: 3.1.0 +info: + title: Provider Capability Registry Logical Contract + version: 0.1.0 + description: >- + Logical contract for the derived provider capability registry and its + consumer surfaces. This models shared capability truth only; it does not + declare new persisted resources or public API commitments. +x-logical-contract: true +x-initial-capability-inventory: + provider_connection_check: + label: Provider connection check + operationTypes: + - provider.connection.check + providerRequirementKeys: + - permissions.admin_consent + inventory_read: + label: Inventory read + operationTypes: + - inventory.sync + providerRequirementKeys: + - permissions.intune_configuration + - permissions.intune_apps + configuration_read: + label: Configuration read + operationTypes: + - compliance.snapshot + providerRequirementKeys: + - permissions.intune_configuration + restore_execute: + label: Restore execute + operationTypes: + - restore.execute + providerRequirementKeys: + - permissions.intune_configuration + - permissions.intune_rbac_assignments + directory_groups_read: + label: Directory groups read + operationTypes: + - directory.groups.sync + providerRequirementKeys: + - permissions.directory_groups + directory_role_definitions_read: + label: Directory role definitions read + operationTypes: + - directory.role_definitions.sync + providerRequirementKeys: + - provider.directory_role_definitions + - permissions.admin_consent +paths: + /logical/provider-connections/{providerConnectionId}/capabilities: + get: + summary: Resolve capability summary for one provider connection + operationId: getProviderConnectionCapabilities + parameters: + - name: providerConnectionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Capability summary for provider-connections and related context + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderConnectionCapabilitySummary' + /logical/managed-environments/{managedEnvironmentId}/required-permission-capabilities: + get: + summary: Resolve capability-grouped required-permissions diagnostics + operationId: getRequiredPermissionCapabilityGroups + parameters: + - name: managedEnvironmentId + in: path + required: true + schema: + type: integer + responses: + '200': + description: Capability groups with nested permission evidence + content: + application/json: + schema: + type: object + required: + - groups + properties: + groups: + type: array + items: + $ref: '#/components/schemas/RequiredPermissionsCapabilityGroup' + /logical/managed-environments/{managedEnvironmentId}/onboarding-provider-capability-assist: + get: + summary: Resolve onboarding capability assist for the current managed environment + operationId: getOnboardingProviderCapabilityAssist + parameters: + - name: managedEnvironmentId + in: path + required: true + schema: + type: integer + - name: providerConnectionId + in: query + required: false + schema: + type: integer + responses: + '200': + description: Capability-first onboarding assist payload + content: + application/json: + schema: + $ref: '#/components/schemas/OnboardingCapabilityAssist' + /logical/provider-operations/{operationType}/capability-gate: + post: + summary: Evaluate provider capability requirements before provider-operation start + operationId: evaluateProviderOperationCapabilityGate + parameters: + - name: operationType + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - managedEnvironmentId + properties: + managedEnvironmentId: + type: integer + providerConnectionId: + type: integer + nullable: true + responses: + '200': + description: Capability gate result for operation start + content: + application/json: + schema: + $ref: '#/components/schemas/OperationCapabilityGateResult' + /logical/provider-capability-explanations: + post: + summary: Translate a provider blocker into a capability-first explanation + operationId: getProviderCapabilityExplanation + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - providerCapabilityKey + properties: + providerCapabilityKey: + $ref: '#/components/schemas/ProviderCapabilityKey' + reasonCode: + type: string + nullable: true + managedEnvironmentId: + type: integer + nullable: true + providerConnectionId: + type: integer + nullable: true + responses: + '200': + description: Capability-first support or contextual-help explanation + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderCapabilityExplanation' +components: + schemas: + ProviderCapabilityKey: + type: string + enum: + - provider_connection_check + - inventory_read + - configuration_read + - restore_execute + - directory_groups_read + - directory_role_definitions_read + ProviderCapabilityStatus: + type: string + enum: + - supported + - missing + - blocked + - unknown + - not_applicable + ProviderCapabilityDefinition: + type: object + required: + - key + - label + - operationTypes + - providerRequirementKeys + properties: + key: + $ref: '#/components/schemas/ProviderCapabilityKey' + label: + type: string + operationTypes: + type: array + items: + type: string + providerRequirementKeys: + type: array + items: + type: string + primaryReasonCodes: + type: array + items: + type: string + remediationRouteKind: + type: string + ProviderCapabilityResult: + type: object + required: + - providerCapabilityKey + - status + - providerRequirementKeys + - primaryMessage + properties: + providerCapabilityKey: + $ref: '#/components/schemas/ProviderCapabilityKey' + status: + $ref: '#/components/schemas/ProviderCapabilityStatus' + reasonCode: + type: string + nullable: true + providerRequirementKeys: + type: array + items: + type: string + lastCheckedAt: + type: string + format: date-time + nullable: true + primaryMessage: + type: string + providerHint: + type: string + nullable: true + evidenceCounts: + type: object + additionalProperties: + type: integer + PermissionEvidenceRow: + type: object + required: + - key + - type + - status + properties: + key: + type: string + type: + type: string + enum: + - application + - delegated + status: + type: string + enum: + - granted + - missing + - error + description: + type: string + nullable: true + features: + type: array + items: + type: string + RequiredPermissionsCapabilityGroup: + type: object + required: + - providerCapabilityKey + - label + - status + - summary + - rows + properties: + providerCapabilityKey: + $ref: '#/components/schemas/ProviderCapabilityKey' + label: + type: string + status: + $ref: '#/components/schemas/ProviderCapabilityStatus' + summary: + type: string + missingRequirementKeys: + type: array + items: + type: string + rows: + type: array + items: + $ref: '#/components/schemas/PermissionEvidenceRow' + ProviderConnectionCapabilitySummary: + type: object + required: + - providerConnectionId + - primaryCapabilityKey + - primaryCapabilitySelectionRule + - capabilities + - nextStep + properties: + providerConnectionId: + type: integer + primaryCapabilityKey: + $ref: '#/components/schemas/ProviderCapabilityKey' + primaryCapabilitySelectionRule: + type: string + enum: + - highest_risk_then_surface_relevance + capabilities: + type: array + items: + $ref: '#/components/schemas/ProviderCapabilityResult' + nextStep: + type: string + nullable: true + OnboardingCapabilityAssist: + type: object + required: + - managedEnvironmentId + - capabilities + properties: + managedEnvironmentId: + type: integer + providerConnectionId: + type: integer + nullable: true + primaryCapabilityKey: + $ref: '#/components/schemas/ProviderCapabilityKey' + capabilities: + type: array + items: + $ref: '#/components/schemas/ProviderCapabilityResult' + nextStep: + type: string + nullable: true + OperationCapabilityGateResult: + type: object + required: + - operationType + - requiredProviderCapabilityKeys + - capabilities + - decision + properties: + operationType: + type: string + requiredProviderCapabilityKeys: + type: array + items: + $ref: '#/components/schemas/ProviderCapabilityKey' + capabilities: + type: array + items: + $ref: '#/components/schemas/ProviderCapabilityResult' + decision: + type: string + enum: + - allow + - block + - unknown + nextStep: + type: string + nullable: true + ProviderCapabilityExplanation: + type: object + required: + - providerCapabilityKey + - primaryMessage + properties: + providerCapabilityKey: + $ref: '#/components/schemas/ProviderCapabilityKey' + reasonCode: + type: string + nullable: true + primaryMessage: + type: string + providerHint: + type: string + nullable: true + requiredPermissionsUrl: + type: string + nullable: true \ No newline at end of file diff --git a/specs/283-provider-capability-registry/data-model.md b/specs/283-provider-capability-registry/data-model.md new file mode 100644 index 00000000..c617013f --- /dev/null +++ b/specs/283-provider-capability-registry/data-model.md @@ -0,0 +1,192 @@ +# Data Model: Provider Capability Registry + +## Existing persisted truth reused + +### ProviderConnection + +Existing persisted fields already provide most capability inputs: + +- `workspace_id` +- `managed_environment_id` +- `provider` +- `entra_tenant_id` as provider-owned Microsoft detail, not the new shared capability vocabulary +- `connection_type` +- `consent_status` +- `verification_status` +- `scopes_granted` +- `last_error_reason_code` +- `last_error_message` +- `last_health_check_at` +- `metadata` + +### Required-permissions evidence + +Existing required-permissions and verification seams already expose row-level or grouped evidence: + +- permission key +- permission type (`application` or `delegated`) +- status (`granted`, `missing`, `error`) +- description +- features +- freshness and verification timing +- `required_permissions_url` + +### Provider-backed operation definitions + +Existing provider-backed operation definitions already provide: + +- `operation_type` +- `module` +- `label` +- user RBAC `required_capability` +- provider binding metadata + +These remain separate from provider application capability truth. + +## Canonical initial capability inventory and mapping + +The initial inventory is bounded to current repo workflows only. + +| Capability Key | Operator Label | Current Operation Types | Initial Provider Requirement Keys | +|---|---|---|---| +| `provider_connection_check` | Provider connection check | `provider.connection.check` | `permissions.admin_consent` | +| `inventory_read` | Inventory read | `inventory.sync` | `permissions.intune_configuration`, `permissions.intune_apps` | +| `configuration_read` | Configuration read | `compliance.snapshot` | `permissions.intune_configuration` | +| `restore_execute` | Restore execute | `restore.execute` | `permissions.intune_configuration`, `permissions.intune_rbac_assignments` | +| `directory_groups_read` | Directory groups read | `directory.groups.sync` | `permissions.directory_groups` | +| `directory_role_definitions_read` | Directory role definitions read | `directory.role_definitions.sync` | `provider.directory_role_definitions`, `permissions.admin_consent` | + +`provider.directory_role_definitions` is intentionally modeled as a provider-owned requirement key because the current diagnostic cluster inventory does not yet expose a narrower shared cluster for that workflow. + +Capability evaluation may additionally inspect provider connection lifecycle, consent, and verification state when resolving `blocked`, `unknown`, or `supported`, but those state inputs remain evaluator inputs rather than new top-level requirement keys. + +## New derived contracts + +### ProviderCapabilityDefinition + +Represents one business-facing provider workflow capability. + +| Field | Type | Notes | +|---|---|---| +| `key` | string | Stable platform-core capability key from the bounded six-key inventory above | +| `label` | string | Operator-facing label | +| `operation_types` | list | Current repo operations that depend on the capability | +| `provider_requirement_keys` | list | Provider-owned identifiers backing the capability, such as cluster keys or raw permission keys | +| `primary_reason_codes` | list | Existing provider reason codes that commonly explain missing, blocked, or unknown states | +| `remediation_route_kind` | string | Logical hint for where the operator should go next, e.g. `required_permissions` | + +### ProviderCapabilityStatus + +Derived status enum with the bounded set: + +- `supported` +- `missing` +- `blocked` +- `unknown` +- `not_applicable` + +Definitions: + +- `supported`: current provider and permission evidence is sufficient for the workflow +- `missing`: required provider-owned prerequisites are absent +- `blocked`: prerequisites exist, but provider connection or provider state blocks execution anyway +- `unknown`: evidence is stale, absent, or not trustworthy enough to decide +- `not_applicable`: the capability does not apply to the current provider binding or current workflow + +### ProviderCapabilityResult + +Represents the derived result of evaluating one capability for one managed environment and optional provider connection. + +| Field | Type | Notes | +|---|---|---| +| `provider_capability_key` | string | Shared capability key | +| `status` | enum | `ProviderCapabilityStatus` | +| `reason_code` | string or null | Existing provider reason code when the result is blocked, missing, or unknown | +| `provider_requirement_keys` | list | The provider-owned requirement identifiers used to justify the result | +| `last_checked_at` | string or null | Derived from the freshest relevant source timestamp | +| `primary_message` | string | Short operator-facing explanation | +| `provider_hint` | string or null | Provider-owned remediation hint | +| `evidence_counts` | array | Optional counts for required, granted, missing, or error evidence | + +### RequiredPermissionsCapabilityGroup + +Used by the required-permissions page to group raw permission evidence under one capability heading. + +| Field | Type | Notes | +|---|---|---| +| `provider_capability_key` | string | Shared capability key | +| `label` | string | Display label | +| `status` | enum | Capability status | +| `summary` | string | Top-level explanation | +| `missing_requirement_keys` | list | Provider-owned raw requirements still missing | +| `rows` | list | Supporting row-level evidence | + +### ProviderConnectionCapabilitySummary + +Derived provider-connection summary aggregate used by provider-connections list and detail surfaces. + +| Field | Type | Notes | +|---|---|---| +| `provider_connection_id` | integer | Current provider connection | +| `primary_capability_key` | string | Capability surfaced first on the summary card or row | +| `primary_capability_selection_rule` | string | Bounded rule: highest-risk status first, then current surface relevance | +| `capabilities` | list | Capability results available for the current connection | +| `next_step` | string or null | One dominant next action for the summary surface | + +### OperationCapabilityGateResult + +Derived operation-start contract used before dispatch. + +| Field | Type | Notes | +|---|---|---| +| `operation_type` | string | Current repo operation type | +| `required_provider_capability_keys` | list | Capability keys needed by the operation | +| `capabilities` | list | Evaluated capability results | +| `decision` | string | `allow`, `block`, or `unknown` | +| `next_step` | string or null | Shared next-step hint, usually the required-permissions path | + +### OnboardingCapabilityAssist + +Derived onboarding-ready summary for a selected provider connection or target workflow. + +| Field | Type | Notes | +|---|---|---| +| `managed_environment_id` | integer | Current managed environment | +| `provider_connection_id` | integer or null | Selected provider connection when present | +| `primary_capability_key` | string | Capability that currently blocks or informs the onboarding step | +| `capabilities` | list | Capability results relevant to the current onboarding step | +| `next_step` | string or null | Usually the required-permissions path or a connection-management action | + +### ProviderCapabilityExplanation + +Derived capability-first explanation used by support or contextual-help consumers. + +| Field | Type | Notes | +|---|---|---| +| `provider_capability_key` | string | Shared capability key | +| `reason_code` | string or null | Existing provider reason code | +| `primary_message` | string | Capability-first explanation | +| `provider_hint` | string or null | Provider-owned remediation hint | +| `required_permissions_url` | string or null | Existing diagnostic or remediation link | + +## Relationships + +- One `ProviderConnection` can produce many `ProviderCapabilityResult` values. +- One provider capability can support many operation types, but the initial slice keeps the mapping bounded to current repo workflows only. +- One required-permissions page can render many `RequiredPermissionsCapabilityGroup` values, each backed by many raw permission rows. +- One operation-start request can depend on one or more provider capability keys, but the initial mapping should stay as small as current repo workflows require. + +## Status mapping rules + +- `missing` is driven by provider-owned requirement evidence that is absent. +- `blocked` is driven by connection-state or provider-state blockers such as disabled connections, consent revoked, or review-required states even when some requirements exist. +- `unknown` is driven by stale or absent evidence where the system cannot safely claim `supported` or `missing`. +- `not_applicable` is used only when the workflow does not apply to the active provider binding. +- Existing user RBAC capability failures are not represented here and remain a separate authorization outcome. + +## Explicit non-goals for data modeling + +- no `provider_capabilities` table +- no persisted capability snapshots +- no new provider-profile entity +- no artifact taxonomy or broader governed-subject taxonomy \ No newline at end of file diff --git a/specs/283-provider-capability-registry/plan.md b/specs/283-provider-capability-registry/plan.md new file mode 100644 index 00000000..907d49b0 --- /dev/null +++ b/specs/283-provider-capability-registry/plan.md @@ -0,0 +1,302 @@ +# Implementation Plan: Provider Capability Registry + +**Branch**: `283-provider-capability-registry` | **Date**: 2026-05-08 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/283-provider-capability-registry/spec.md` + +## Summary + +Prepare the next reserved provider-boundary slice by introducing one provider capability registry and derived capability-evaluation path over the repo's existing provider connection, required-permissions, onboarding, blocked-operation, and support-diagnostic seams. The narrow implementation path reuses the current provider operation registry, provider operation start gate, required-permissions matrix, provider reason translation, provider-connections resource, onboarding wizard, and support links while explicitly rejecting a provider-capability table, a provider framework, a user RBAC rewrite, a broader taxonomy, and adjacent cutover work from Specs `284` through `287`. + +This plan is intentionally bounded. Filament remains v5 on Livewire v4, provider registration remains in `apps/platform/bootstrap/providers.php`, `ProviderConnectionResource` remains non-globally-searchable, existing destructive provider-connection actions stay confirmation-protected and server-authorized, and raw Graph permission names remain provider-owned evidence rather than the new primary operator vocabulary. + +## Inherited Baseline / Explicit Delta + +### Inherited baseline + +- Spec `279` already completed the managed-environment core cutover and is historical prerequisite context only. +- Spec `280` already prepared the workspace-first shell and remains separate adjacent context only. +- Spec `281` already prepared the provider-neutral target-scope and provider-identity baseline and is an implementation prerequisite for `283`. +- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` already exists with `List`, `Create`, `View`, and `Edit` pages, remains `protected static bool $isGloballySearchable = false;`, and already groups mutating actions behind confirmation-protected Filament actions. +- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` already lives under the workspace-first managed-environment route shell and already keeps its actions inside the page body rather than the page header. +- `apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php` already groups raw permission rows into diagnostic clusters such as admin consent, directory/groups, Intune configuration, apps, RBAC, and scripts, but it does not yet publish workflow capability truth. +- `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` already maps operation types to user RBAC capability requirements and provider bindings, but it does not yet map those operations to provider application capabilities. +- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` already blocks or starts provider-backed work through one shared path, but it still explains missing prerequisites through reason codes and raw requirement detail rather than a stable capability contract. +- `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, contextual-help catalog or resolver seams, and support-diagnostic consumers already translate provider blockers and route operators to `RequiredPermissionsLinks`, but they still lead with provider-specific requirement language in several cases. +- `apps/platform/app/Models/ProviderConnection.php` already persists consent state, verification state, `scopes_granted`, and metadata, so `283` must treat provider capability state as derived truth rather than add another table. + +### Explicit delta in this plan + +- Introduce one bounded provider capability definition and evaluation layer over the existing provider connection, required-permissions, and blocked-operation seams. +- Keep provider capability state derived and request-time or snapshot-derived; do not add a provider-capability table or ledger. +- Map the current provider-backed operation types to explicit provider capability keys. +- Reuse the required-permissions page as the canonical diagnostic deep dive, but group or summarize raw requirement rows under shared capability headings and statuses. +- Reuse `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` as the operator-facing summary consumers for the new capability contract. +- Reuse `ProviderReasonTranslator`, `ProviderNextStepsRegistry`, and existing support or product-knowledge links so blocked-operation and diagnostic guidance adopts the same capability vocabulary. +- Keep raw Graph permission names, Intune RBAC prerequisite detail, consent links, and provider portal metadata nested inside provider-owned remediation or evidence blocks. +- Keep Specs `284` through `287` explicitly deferred. + +## Technical Context + +**Language/Version**: PHP 8.4.15, Laravel 12.52 +**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, existing provider operation, provider reason, onboarding, and required-permissions seams +**Storage**: PostgreSQL, no new persistence or schema change in this slice +**Testing**: Pest unit tests, Pest feature tests, and one Pest browser smoke +**Validation Lanes**: fast-feedback, confidence, browser +**Target Platform**: Laravel monolith in `apps/platform` +**Project Type**: web application +**Performance Goals**: preserve current provider-connection, onboarding, and required-permissions responsiveness while changing only derived capability evaluation and summary presentation; no new remote inline work or asset path is introduced +**Constraints**: no provider-capability table, no provider framework, no user RBAC changes, no route-cutover work from Spec `280`, no provider-identity extraction work from Spec `281`, provider registration stays in `apps/platform/bootstrap/providers.php`, and `ProviderConnectionResource` stays non-globally-searchable +**Scale/Scope**: one shared capability contract over the existing Microsoft provider implementation and current provider-backed workflows only + +## Likely Affected Repo Surfaces + +- `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` +- `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` +- `apps/platform/app/Support/Providers/ProviderReasonCodes.php` +- `apps/platform/app/Support/Providers/ProviderReasonTranslator.php` +- `apps/platform/app/Support/Providers/ProviderNextStepsRegistry.php` +- `apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php` +- `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` +- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` +- `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` +- `apps/platform/app/Filament/Resources/ProviderConnectionResource.php` +- `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` +- `apps/platform/app/Support/Links/RequiredPermissionsLinks.php` +- `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php` +- `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php` +- `apps/platform/app/Support/TenantDashboard/TenantDashboardSummaryBuilder.php` if that surface already consumes the shared provider prerequisite explanation and must stay aligned without becoming a separate dashboard initiative +- `apps/platform/config/provider_boundaries.php` +- `apps/platform/config/intune_permissions.php` only if the implementation needs to read existing provider-owned requirement identifiers from the current config rather than re-declare them locally +- new bounded support files under `apps/platform/app/Support/Providers/Capabilities/` only if implementation needs a small dedicated namespace for the registry, status enum, and evaluator +- representative proof files under `apps/platform/tests/Unit/Providers/`, `apps/platform/tests/Unit/Verification/`, `apps/platform/tests/Feature/Providers/`, `apps/platform/tests/Feature/Filament/`, `apps/platform/tests/Feature/Onboarding/`, `apps/platform/tests/Feature/RequiredPermissions/`, `apps/platform/tests/Feature/SupportDiagnostics/`, and `apps/platform/tests/Browser/` + +## Filament v5 / Capability Surface Notes + +- **Livewire v4.0+ compliance**: all touched Filament work remains on Filament v5 with Livewire v4. +- **Provider registration location**: provider registration stays in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`. +- **Global search rule**: `ProviderConnectionResource` remains non-globally-searchable and keeps its `View` and `Edit` pages. No new searchable resource is introduced by this slice. +- **Destructive actions**: touched provider-connection mutations keep the existing `->action(...)`, `->requiresConfirmation()`, and server-authorization contracts. `Grant admin consent` remains navigation-only. +- **Asset strategy**: no new asset registration or deploy-step change is planned. + +## Provider Capability Contract Fit + +- Introduce one bounded capability definition source for current-release provider-backed workflows only. +- Keep the shared capability-key inventory limited to the workflows already present in repo truth: + - `provider_connection_check` + - `inventory_read` + - `configuration_read` + - `restore_execute` + - `directory_groups_read` + - `directory_role_definitions_read` +- Keep capability status values limited to the derived family from the spec: `supported`, `missing`, `blocked`, `unknown`, and `not_applicable`. +- Keep capability definitions platform-core, but keep provider requirement mappings provider-owned. That means capability definitions can point to provider-owned requirement keys such as cluster identifiers or Graph permission names without promoting those raw identifiers into the top-level operator vocabulary. +- Prefer one small code-first registry plus evaluator namespace over a new config-first framework or a new table. If implementation discovers that one small code-first registry cannot stay reviewable, that change must be re-justified rather than added silently. +- Reuse existing permission-cluster evidence and provider-connection state as inputs. The capability layer should not replace those inputs; it should standardize how they are interpreted by workflow consumers. + +## UI / Surface Guardrail Plan + +- **Guardrail scope**: changed surfaces +- **Native vs custom classification summary**: mixed native Filament resource plus existing custom onboarding wizard +- **Shared-family relevance**: provider summary, blocked guidance, onboarding readiness, required-permissions diagnostics, support translation +- **State layers in scope**: page, detail, modal, Livewire state, URL-query +- **Audience modes in scope**: operator-MSP, support-platform +- **Decision/diagnostic/raw hierarchy plan**: capability-first summary, diagnostics-second evidence, provider-raw-third remediation detail +- **Raw/support gating plan**: provider-specific requirement detail stays nested or lower-priority; raw permission rows stay on the diagnostic page rather than the top-level summary +- **One-primary-action / duplicate-truth control**: provider-connections and onboarding surface one dominant next step from the capability result and delegate the full proof to the required-permissions page +- **Handling modes by drift class or surface**: review-mandatory +- **Repository-signal treatment**: review-mandatory until provider-connections, onboarding, required-permissions, and blocked-operation messaging all use the same capability labels +- **Special surface test profiles**: standard-native-filament, workflow-hub, shared-detail-family +- **Required tests or browser smoke**: functional-core, state-contract, browser-smoke +- **Exception path and spread control**: none; the slice removes explanation drift rather than adding a new exception +- **Active feature PR close-out entry**: Guardrail + +## Shared Pattern & System Fit + +- **Cross-cutting feature marker**: yes +- **Systems touched**: provider operation registry, provider operation start gate, required-permissions diagnostics, onboarding readiness, provider connection summaries, provider reason translation, contextual help, and support-diagnostic guidance +- **Shared abstractions reused**: `ProviderOperationRegistry`, `ProviderOperationStartGate`, `ProviderReasonTranslator`, `ProviderNextStepsRegistry`, `TenantPermissionCheckClusters`, `TenantRequiredPermissionsViewModelBuilder`, `ProviderConnectionSurfaceSummary`, and `RequiredPermissionsLinks` +- **New abstraction introduced? why?**: one small registry plus evaluator namespace is expected because multiple existing shared consumers need one stable capability contract and no current shared abstraction owns that concept yet +- **Why the existing abstraction was sufficient or insufficient**: existing abstractions already own provider connection state, permission evidence, or blocked-operation explanation, but none of them own the workflow-capability concept across all current consumers +- **Bounded deviation / spread control**: provider-owned Graph permission names, Intune RBAC detail, consent links, and portal metadata remain nested evidence only and must not define the shared capability contract + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Central contract reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, and the current `OperationRunService` lifecycle path +- **Delegated UX behaviors**: blocked-versus-started behavior, queued intent messaging, run links, capability-driven remediation hints, and provider-safe follow-up routing stay delegated to the existing shared provider-operation path +- **Surface-owned behavior kept local**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` keep only initiation affordances, summary placement, and assist entry points +- **Queued DB-notification policy**: `N/A` +- **Terminal notification path**: existing central lifecycle mechanism +- **Exception path**: none + +## Provider Boundary & Portability Fit + +- **Shared provider/platform boundary touched?**: yes +- **Provider-owned seams**: raw Graph permission names, Intune RBAC remediation detail, admin-consent links, required-permissions URLs, portal links, and any provider-specific troubleshooting metadata +- **Platform-core seams**: capability definitions, capability status evaluation, operation-to-capability mapping, blocked-operation capability summary, shared capability labels, and shared diagnostic grouping +- **Neutral platform terms / contracts preserved**: `provider capability`, `capability key`, `capability status`, `provider connection`, `managed environment`, `target workflow`, and `provider requirement` +- **Retained provider-specific semantics and why**: the current Microsoft provider still needs its own raw permission names, consent guidance, and Intune RBAC detail for operators to fix blockers. Those details remain explicit provider-owned evidence. +- **Bounded extraction or follow-up path**: no broader taxonomy or multi-provider framework work in this slice; that remains with Specs `284` through `287` + +## Constitution Check + +*GATE: Must pass before implementation begins and again after design artifacts are complete.* + +- Inventory-first / snapshot truth: PASS. The slice derives capability truth from existing provider and permission evidence. +- Read/write separation: PASS. No new remote-write workflow is introduced. +- Graph contract path: PASS. No new Graph endpoint or contract-registry work is added. +- Deterministic capabilities: PASS with implementation condition. Capability evaluation must be testable and deterministic from the existing provider and permission inputs. +- RBAC-UX plane separation: PASS. `/admin` versus `/system` remains unchanged. +- Workspace isolation: PASS. Workspace membership remains the first boundary. +- Managed-environment isolation: PASS. Managed-environment entitlement remains the second boundary. +- Destructive action discipline: PASS by preservation. Existing confirmation-protected provider-connection mutations remain unchanged. +- Global search safety: PASS. `ProviderConnectionResource` stays non-globally-searchable. +- OperationRun / Ops-UX: PASS. The slice reuses the shared provider-operation start path and changes only its prerequisite contract. +- Data minimization: PASS. No new persistence or provider-capability ledger is introduced. +- Test governance: PASS. Proof stays bounded to unit, feature, and one browser smoke. +- Proportionality / no premature abstraction: PASS with implementation condition. Any new support namespace must stay narrow and current-release only. +- Persisted truth / behavioral state: PASS. One new derived state family is introduced; no new persistence is introduced. +- UI semantics / shared pattern first / Filament-native UI: PASS. Existing resource, page, and wizard surfaces remain the primary operator paths. +- Provider boundary: PASS with implementation condition. Raw provider requirement detail must remain secondary evidence rather than reappearing as the primary shared label set. + +**Gate evaluation**: PASS. + +**Post-design re-check**: PASS while `research.md`, `data-model.md`, `quickstart.md`, `contracts/provider-capability-registry.logical.openapi.yaml`, and `checklists/requirements.md` stay aligned on the same capability keys, status family, derived-truth posture, and proof commands. + +## Test Governance Check + +- **Test purpose / classification by changed surface**: Unit, Feature, Browser +- **Affected validation lanes**: fast-feedback, confidence, browser +- **Why this lane mix is the narrowest sufficient proof**: the registry and evaluator logic are pure derivation and deserve unit proof; the shared start gate, provider-connections, onboarding, required-permissions page, and support translation need feature proof; one browser smoke is enough to prove the real operator path from provider connection or onboarding into the diagnostic page +- **Narrowest proving command(s)**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderCapabilityRegistryTest.php tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderCapabilityEvaluationTest.php tests/Feature/Providers/ProviderOperationCapabilityGateTest.php tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)` +- **Fixture / helper / factory / seed / context cost risks**: moderate because proof needs workspace, managed environment, provider connection, permission evidence, and blocked-operation context without widening shared fixture defaults +- **Expensive defaults or shared helper growth introduced?**: no; any new capability test helper should stay feature-local and opt-in +- **Heavy-family additions, promotions, or visibility changes**: none beyond one bounded browser smoke +- **Surface-class relief / special coverage rule**: standard-native-filament relief for provider-connections and required-permissions; one workflow-hub smoke for onboarding continuity +- **Closing validation and reviewer handoff**: rerun the commands above, verify that no provider-capability table appears, verify that the registry stays current-release-only, verify that provider-connections and onboarding use the same capability labels, verify that the required-permissions page groups evidence under those same labels, verify that blocked-operation context carries capability information, and verify that raw Graph permission names remain secondary evidence +- **Budget / baseline / trend follow-up**: contained feature-local increase only +- **Review-stop questions**: did the implementation add persistence, did it invent future-facing capability keys, did it widen into RBAC or taxonomy work, did any touched surface keep a page-local permission vocabulary, did blocked-operation context stay on reason-only or raw permission language +- **Escalation path**: `reject-or-split` if implementation introduces persistence, a provider framework, a user RBAC rewrite, or broader cutover work from adjacent specs +- **Active feature PR close-out entry**: Guardrail +- **Why no dedicated follow-up spec is needed**: adjacent follow-up work already exists as Specs `284` through `287`; `283` only needs the bounded capability slice itself + +## Review Checklist Status + +- **Review checklist artifact**: `checklists/requirements.md` +- **Review outcome class**: `implementation-ready` +- **Workflow outcome**: `keep` +- **Test-governance outcome**: `keep` +- **Escalation rule**: if implementation adds persistence, broad future-facing keys, or adjacent-spec scope, flip the workflow outcome to `split` or `reject-or-split` + +## Rollout Considerations + +- Land the registry definitions, evaluator, and shared consumer updates as one bounded slice so provider-connections, onboarding, required-permissions diagnostics, and provider-operation blocking all converge atomically. +- Update the shared evaluation and translation seams before polishing page copy so the touched surfaces inherit the same contract rather than reformatting raw evidence separately. +- Keep provider-owned evidence and remediation nested from the start; otherwise the later UI pass will still have to untangle raw Microsoft-first summaries. +- Keep dashboard or support surfaces limited to consumers of the shared capability contract and do not let them grow into a separate productization initiative in this slice. + +## Risk Controls + +- Reject any implementation that introduces a provider-capability table, ledger, or snapshot model. +- Reject any implementation that creates a future-facing provider framework or tries to solve multi-provider routing, taxonomy, or UI copy in the same slice. +- Reject any implementation that leaves provider-connections, onboarding, and required-permissions diagnostics on different capability or requirement vocabularies. +- Reject any implementation that collapses user RBAC capability failures into provider application capability failures. +- Reject any implementation that promotes raw Graph permission names back into the primary operator summary on touched surfaces. + +## Research & Design Outputs + +- `research.md` records the bounded derivation decisions, the capability-key inventory, the provider-owned evidence rules, and the rejected alternatives. +- `data-model.md` captures the derived capability definition, result, evidence, grouped diagnostic view, and operation-gate contracts. +- `quickstart.md` gives reviewers the bounded proof flow and exact commands. +- `contracts/provider-capability-registry.logical.openapi.yaml` models the logical capability summary, diagnostic grouping, onboarding assist, support explanation, and operation-start contract. +- `checklists/requirements.md` records package readiness, boundedness, and outcome state. + +## Project Structure + +### Documentation (this feature) + +```text +specs/283-provider-capability-registry/ +├── checklists/ +│ └── requirements.md +├── contracts/ +│ └── provider-capability-registry.logical.openapi.yaml +├── data-model.md +├── plan.md +├── quickstart.md +├── research.md +├── spec.md +└── tasks.md +``` + +### Source Code (expected implementation surfaces) + +```text +apps/platform/ +├── app/ +│ ├── Filament/ +│ │ ├── Pages/ +│ │ │ ├── TenantRequiredPermissions.php +│ │ │ └── Workspaces/ +│ │ │ └── ManagedTenantOnboardingWizard.php +│ │ └── Resources/ +│ │ └── ProviderConnectionResource.php +│ ├── Models/ +│ │ └── ProviderConnection.php +│ ├── Services/ +│ │ ├── Intune/ +│ │ │ └── TenantRequiredPermissionsViewModelBuilder.php +│ │ └── Providers/ +│ │ ├── ProviderOperationRegistry.php +│ │ └── ProviderOperationStartGate.php +│ └── Support/ +│ ├── Links/ +│ │ └── RequiredPermissionsLinks.php +│ ├── ProductKnowledge/ +│ │ ├── ContextualHelpCatalog.php +│ │ └── ContextualHelpResolver.php +│ ├── Providers/ +│ │ ├── ProviderNextStepsRegistry.php +│ │ ├── ProviderReasonCodes.php +│ │ ├── ProviderReasonTranslator.php +│ │ ├── TargetScope/ +│ │ │ └── ProviderConnectionSurfaceSummary.php +│ │ └── Capabilities/ +│ │ ├── ProviderCapabilityRegistry.php +│ │ ├── ProviderCapabilityResult.php +│ │ ├── ProviderCapabilityStatus.php +│ │ └── ProviderCapabilityEvaluator.php +│ ├── TenantDashboard/ +│ │ └── TenantDashboardSummaryBuilder.php +│ └── Verification/ +│ └── TenantPermissionCheckClusters.php +├── config/ +│ ├── intune_permissions.php +│ └── provider_boundaries.php +└── tests/ + ├── Browser/ + │ └── Spec283ProviderCapabilityRegistrySmokeTest.php + ├── Feature/ + │ ├── Filament/ + │ │ └── ProviderConnectionCapabilitySummaryTest.php + │ ├── Onboarding/ + │ │ └── ManagedTenantOnboardingCapabilityAssistTest.php + │ ├── Providers/ + │ │ ├── ProviderCapabilityEvaluationTest.php + │ │ └── ProviderOperationCapabilityGateTest.php + │ ├── RequiredPermissions/ + │ │ └── RequiredPermissionsCapabilityGroupingTest.php + │ └── SupportDiagnostics/ + │ └── ProviderCapabilityReasonTranslationTest.php + └── Unit/ + ├── Providers/ + │ └── ProviderCapabilityRegistryTest.php + └── Verification/ + └── TenantPermissionCapabilityMappingTest.php +``` + +**Structure Decision**: keep the implementation inside `apps/platform` and add only one small support namespace for provider capability definitions and derived evaluation. Reuse existing Filament, provider-operation, required-permissions, and support-diagnostic seams instead of creating a new module or package. \ No newline at end of file diff --git a/specs/283-provider-capability-registry/quickstart.md b/specs/283-provider-capability-registry/quickstart.md new file mode 100644 index 00000000..83d93eb1 --- /dev/null +++ b/specs/283-provider-capability-registry/quickstart.md @@ -0,0 +1,74 @@ +# Quickstart: Provider Capability Registry + +## Purpose + +Use this guide to review or later implement Spec `283` as one bounded provider-capability slice. + +## Preconditions + +1. Spec `281` provider-boundary groundwork is already present on the implementation branch. +2. Work stays inside `apps/platform` and this spec package. +3. No application implementation from adjacent Specs `284` through `287` is pulled in. + +## Reviewer flow + +1. Read [spec.md](./spec.md), [plan.md](./plan.md), [research.md](./research.md), and [data-model.md](./data-model.md) together. +2. Confirm the package introduces one derived capability registry and no new persistence. +3. Confirm the initial capability-key set stays bounded to current repo workflows only. +4. Confirm provider-owned evidence such as raw Graph permission names remains nested, not primary operator vocabulary. +5. Confirm provider-connections, onboarding, required-permissions diagnostics, blocked-operation messaging, and support guidance all point at the same capability contract. + +## Suggested implementation order + +1. Add the small provider capability registry, status enum, and evaluator. +2. Map current provider-backed operations to capability keys in the shared provider operation registry. +3. Update the shared provider-operation start gate and blocked-result context. +4. Update provider-connections summaries and onboarding capability assist. +5. Update required-permissions grouping and shared provider reason translation. +6. Run the exact bounded proof commands below. + +## Narrow proof commands + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \ + (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \ + tests/Unit/Providers/ProviderCapabilityRegistryTest.php \ + tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php) +``` + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \ + (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \ + tests/Feature/Providers/ProviderCapabilityEvaluationTest.php \ + tests/Feature/Providers/ProviderOperationCapabilityGateTest.php \ + tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php \ + tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php \ + tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php \ + tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php) +``` + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \ + (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact \ + tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php) +``` + +```bash +export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && \ + (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent) +``` + +## Expected smoke path + +1. Open one provider connection. +2. Verify the page shows a capability-first summary for at least one workflow. +3. Use the required-permissions or onboarding assist entry point. +4. Confirm the diagnostic page shows the same capability label and supporting permission evidence. +5. Trigger one blocked or allowed provider-backed workflow and confirm the shared start outcome uses the same capability language. + +## Stop conditions + +- Stop if implementation tries to add a provider-capability table or provider ledger. +- Stop if new capability keys describe workflows that do not yet exist in repo truth. +- Stop if raw Graph permission names return as the primary operator-facing summary on the touched surfaces. +- Stop if the slice widens into routing, RBAC, taxonomy, copy, or quality-gate work reserved for later specs. \ No newline at end of file diff --git a/specs/283-provider-capability-registry/research.md b/specs/283-provider-capability-registry/research.md new file mode 100644 index 00000000..d01eb7d3 --- /dev/null +++ b/specs/283-provider-capability-registry/research.md @@ -0,0 +1,68 @@ +# Research: Provider Capability Registry + +## Decision 1: Provider capability state stays derived, not persisted + +- **Decision**: keep provider capability truth derived from existing provider connection state, required-permissions evidence, consent posture, verification posture, and provider-binding metadata. +- **Why**: the repo already stores the underlying truth in `ProviderConnection`, required-permissions evidence, and blocked-operation context. A provider-capability table would duplicate that truth without a current-release lifecycle need. +- **Alternatives considered**: + - provider-capability snapshot table: rejected because it introduces new persistence, drift risk, and lifecycle questions with no current-release benefit + - page-local capability arrays: rejected because they preserve current explanation drift across provider-connections, onboarding, required-permissions diagnostics, and blocked-operation messaging + +## Decision 2: Use one small code-first registry plus evaluator + +- **Decision**: prefer one small support namespace for capability definitions, status enum, and evaluation over a new config-first framework or broad provider abstraction. +- **Why**: the slice needs one shared capability concept across multiple existing consumers. A small code-first registry is easier to bound and test than another config namespace plus generic loader machinery. +- **Alternatives considered**: + - expand `config/intune_permissions.php` into the sole capability source: rejected because the file already models provider-owned raw permission detail, not shared workflow capability truth + - use only `ProviderOperationRegistry`: rejected because required-permissions diagnostics and onboarding also need capability truth outside operation-start paths + +## Decision 3: Capability keys stay limited to current repo workflows + +- **Decision**: limit the initial provider capability inventory to current workflows already present in repo truth: provider connection checks, inventory sync, compliance snapshot, restore execution, directory groups sync, and directory role-definition sync. +- **Canonical initial key set**: + - `provider_connection_check` -> `provider.connection.check` + - `inventory_read` -> `inventory.sync` + - `configuration_read` -> `compliance.snapshot` + - `restore_execute` -> `restore.execute` + - `directory_groups_read` -> `directory.groups.sync` + - `directory_role_definitions_read` -> `directory.role_definitions.sync` +- **Why**: the candidate backlog listed broader future-facing examples, but current-release truth only justifies keys for existing workflows and diagnostics. +- **Alternatives considered**: + - future-facing keys such as `review_publish` or `evidence_snapshot_write`: rejected because they describe adjacent or not-yet-real provider workflows + - capability keys copied directly from raw Graph permission names: rejected because that would keep provider-owned requirement detail as platform-core vocabulary + +## Decision 4: Existing provider reason codes remain blocking reason truth + +- **Decision**: keep current provider reason codes as the blocking or degraded reason truth and add capability keys or capability status as the workflow-level explanation layer. +- **Why**: the repo already routes blocked outcomes, contextual help, and support diagnostics through provider reason codes. Replacing that system entirely would widen scope and duplicate responsibility. +- **Alternatives considered**: + - a second capability-specific reason-code family: rejected because it adds another semantic layer without replacing the existing one + - no reason-code reference on capability results: rejected because blocked and unknown states still need stable downstream machine-readable reasons + +## Decision 5: Required Permissions stays the canonical diagnostic deep dive + +- **Decision**: keep `TenantRequiredPermissions` as the canonical diagnostic page and make it capability-aware by grouping or summarizing raw provider requirements under capability headings. +- **Why**: the page already exists, already supports filtering and deep inspection, and already has safe links from onboarding and product knowledge. +- **Alternatives considered**: + - a new provider-capability diagnostic page: rejected because it duplicates an existing deep-dive surface + - inline raw permission evidence on provider-connections or onboarding only: rejected because it would spread the diagnostic matrix across summary-first surfaces + +## Decision 6: Support and contextual-help consumers must adopt the same vocabulary + +- **Decision**: shared help and support-diagnostic consumers should adopt the capability-first explanation if they already consume the same provider blocker. +- **Why**: otherwise the same provider blocker would still read differently on the main surfaces versus diagnostic surfaces. +- **Alternatives considered**: + - leave help and support surfaces untouched: rejected because it preserves one of the biggest remaining explanation drifts in the repo + +## Implementation prerequisite + +- Spec `281` must already be present on the implementation branch because `283` assumes the provider-neutral target-scope and provider-identity baseline prepared there. + +## Explicit non-goals carried into design + +- No provider-capability table or ledger +- No broader provider-neutral artifact taxonomy +- No user RBAC changes +- No provider-profile table +- No routing cutover +- No copy-neutralization or no-legacy enforcement pack work \ No newline at end of file diff --git a/specs/283-provider-capability-registry/spec.md b/specs/283-provider-capability-registry/spec.md new file mode 100644 index 00000000..e209800d --- /dev/null +++ b/specs/283-provider-capability-registry/spec.md @@ -0,0 +1,372 @@ +# Feature Specification: Provider Capability Registry + +**Feature Branch**: `283-provider-capability-registry` +**Created**: 2026-05-08 +**Status**: Ready +**Input**: User description: "Use the next-best-prep workflow with explicit manual promotion for reserved slot `283` and prepare the `Provider Capability Registry v1` package without implementing application code." + +## Spec Candidate Check + +- **Problem**: TenantPilot already has provider connection health checks, required-permissions diagnostics, onboarding verification clusters, blocked provider-operation outcomes, and support guidance, but those seams still describe the same provider requirement truth in different ways. Operators see Microsoft Graph permission names, cluster titles, reason codes, and blocked-operation prose without one provider-neutral capability contract that says which workflow is actually supported for the current managed environment. +- **Today's failure**: `ProviderOperationStartGate`, `ProviderReasonTranslator`, `TenantPermissionCheckClusters`, `TenantRequiredPermissions`, `ManagedTenantOnboardingWizard`, and `ProviderConnectionResource` all expose permission or prerequisite truth, but the platform still lacks one business-facing capability layer above those details. The same missing grant can read as a raw Graph permission on one surface, an Intune RBAC blocker on another, and a generic provider failure in run context. +- **User-visible improvement**: Operators get one stable capability vocabulary for provider-backed workflows such as provider connection checks, inventory sync, compliance snapshot, restore execution, directory group sync, and directory role-definition sync. Provider-specific remediation hints and raw Graph permission names remain available, but they become secondary evidence instead of primary operator truth. +- **Smallest enterprise-capable version**: Introduce one derived provider capability registry and evaluation layer over the current provider connection, permission-cluster, onboarding, and provider-operation seams. Reuse the existing required-permissions page, provider-connections resource, onboarding wizard, provider reason translation, and operation-start gate. Do not add a provider-capability table, no new RBAC capability family, no new provider implementation, and no generic workflow engine. +- **Explicit non-goals**: No new persisted provider-capability ledger, no dedicated provider-profile table, no user RBAC rewrite, no broader provider-neutral artifact taxonomy, no routing cutover from Spec `280`, no provider-connection identity extraction from Spec `281`, no copy or localization neutralization from Spec `286`, no no-legacy enforcement pack from Spec `287`, and no AWS, Google, or Okta provider implementation. +- **Permanent complexity imported**: One bounded registry of provider capability definitions, one derived capability-status family, one evaluator or presenter path that maps existing permission and provider-state truth to capability results, and focused unit, feature, and browser proof. No new persisted truth is imported. +- **Why now**: The workspace-first cutover pack already moved core environment identity in Spec `279`, prepared the route shell in Spec `280`, and prepared provider identity or target-scope neutralization in Spec `281`. The next remaining provider boundary gap is workflow capability truth. Without it, later artifact taxonomy, copy, and quality-gate work would still inherit raw Microsoft permission language and ad hoc prerequisite logic. +- **Why not local**: The gap is shared. A page-local rename on provider-connections or onboarding would leave `ProviderOperationStartGate`, support diagnostics, blocked-operation messaging, and the required-permissions diagnostic matrix out of sync. The capability contract has to sit above the current shared seams, not inside one UI page. +- **Approval class**: Core Enterprise +- **Red flags triggered**: New abstraction, new derived state family, and a cross-cutting interaction contract. Defense: current repo truth already has multiple consumers and multiple conflicting explanations for the same provider prerequisite state. One bounded registry plus evaluator is the narrowest correct answer and explicitly rejects new persistence, a provider framework, or wider cutover work. +- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12** +- **Decision**: approve + +## Spec Scope Fields + +- **Scope**: workspace +- **Primary Routes**: + - `/admin/provider-connections` + - `/admin/provider-connections/{record}` + - `/admin/provider-connections/{record}/edit` + - `/admin/workspaces/{workspace}/environments/{managed_environment}/required-permissions` + - named onboarding routes `admin.onboarding` and `admin.onboarding.draft` + - provider-backed start actions that already enqueue `provider.connection.check`, `inventory.sync`, `compliance.snapshot`, `restore.execute`, `directory.groups.sync`, and `directory.role_definitions.sync` +- **Data Ownership**: + - `ProviderConnection` remains the existing workspace-owned, managed-environment-scoped binding record + - current required-permissions snapshots, provider verification state, consent state, and operation-start context remain the underlying source inputs + - provider capability definitions and evaluated capability results remain derived runtime truth in this slice and MUST NOT create a new persisted table or artifact + - `OperationRun` remains execution truth and may carry derived capability context, but not a second persisted capability ledger +- **RBAC**: + - workspace membership remains the first access boundary + - managed-environment entitlement remains the second access boundary + - existing user capability gates such as `PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_MANAGE_DEDICATED`, `PROVIDER_RUN`, `TENANT_MANAGE`, and `TENANT_SYNC` remain unchanged and MUST NOT be conflated with provider application capabilities + +## Canonical Initial Capability Inventory + +The initial provider capability registry is intentionally limited to current repo workflows only. + +| Capability Key | Operator Label | Current Operation Types | Initial Provider Requirement Keys | +|---|---|---|---| +| `provider_connection_check` | Provider connection check | `provider.connection.check` | `permissions.admin_consent` | +| `inventory_read` | Inventory read | `inventory.sync` | `permissions.intune_configuration`, `permissions.intune_apps` | +| `configuration_read` | Configuration read | `compliance.snapshot` | `permissions.intune_configuration` | +| `restore_execute` | Restore execute | `restore.execute` | `permissions.intune_configuration`, `permissions.intune_rbac_assignments` | +| `directory_groups_read` | Directory groups read | `directory.groups.sync` | `permissions.directory_groups` | +| `directory_role_definitions_read` | Directory role definitions read | `directory.role_definitions.sync` | `provider.directory_role_definitions`, `permissions.admin_consent` | + +This bounded inventory is authoritative for Spec `283`. Any broader capability family belongs to follow-up work and MUST NOT be added silently in this slice. + +Capability evaluation may additionally inspect provider connection lifecycle, consent, and verification state when resolving `blocked`, `unknown`, or `supported`, but those state inputs are not promoted into new top-level requirement keys. + +## Cross-Cutting / Shared Pattern Reuse + +- **Cross-cutting feature?**: yes +- **Interaction class(es)**: status messaging, blocked-operation guidance, onboarding readiness summaries, provider-connection summaries, required-permissions diagnostics, related links, and support-diagnostic translations +- **Systems touched**: + - `ProviderOperationRegistry` + - `ProviderOperationStartGate` + - `ProviderReasonTranslator` + - `ProviderNextStepsRegistry` + - `TenantPermissionCheckClusters` + - `TenantRequiredPermissionsViewModelBuilder` + - `TenantRequiredPermissions` + - `ManagedTenantOnboardingWizard` + - `ProviderConnectionResource` + - `RequiredPermissionsLinks` + - contextual-help and support-diagnostic consumers of provider reason translation +- **Existing pattern(s) to extend**: required-permissions diagnostics, provider reason translation, provider-operation start gating, provider-connection surface summaries, and onboarding verification assists +- **Shared contract / presenter / builder / renderer to reuse**: `ProviderOperationRegistry`, `ProviderOperationStartGate`, `ProviderReasonTranslator`, `TenantPermissionCheckClusters`, `TenantRequiredPermissionsViewModelBuilder`, `ProviderConnectionSurfaceSummary`, `RequiredPermissionsLinks`, and the existing Filament resource or page surface contracts +- **Why the existing shared path is sufficient or insufficient**: the existing seams already gather the right provider, permission, and workflow evidence. What is insufficient is that each seam names or groups that evidence differently. The feature should normalize those consumers around one capability contract, not build another workflow surface. +- **Allowed deviation and why**: none. Provider-specific remediation detail may remain nested inside provider-owned help, consent, or permission evidence, but that is part of the shared contract design rather than a surface-level exception. +- **Consistency impact**: provider-connections, onboarding, required-permissions diagnostics, blocked-operation messaging, contextual help, and support diagnostics must all use the same capability key, status, and primary explanation before showing provider-specific raw permission detail. +- **Review focus**: reviewers must verify that no second mapping path survives on onboarding, provider-connections, or required-permissions summaries; that capability results remain derived; and that raw Graph permission names are demoted to secondary evidence rather than primary operator truth. + +## OperationRun UX Impact + +- **Touches OperationRun start/completion/link UX?**: yes +- **Shared OperationRun UX contract/layer reused**: `ProviderOperationStartGate`, `ProviderOperationStartResultPresenter`, `OperationUxPresenter`, `ProviderReasonTranslator`, and the existing `OperationRunService` lifecycle path +- **Delegated start/completion UX behaviors**: blocked-versus-started outcomes, queued intent messaging, run link generation, provider-specific remediation guidance, dedupe-or-busy behavior, and provider-safe URL resolution stay on the existing shared start path +- **Local surface-owned behavior that remains**: `ProviderConnectionResource` and `ManagedTenantOnboardingWizard` keep only initiation inputs, summary placement, and capability-assist entry points +- **Queued DB-notification policy**: `N/A` - unchanged shared policy +- **Terminal notification path**: existing central lifecycle mechanism +- **Exception required?**: none + +## Provider Boundary / Platform Core Check + +- **Shared provider/platform boundary touched?**: yes +- **Boundary classification**: mixed +- **Seams affected**: + - platform-core: provider capability definitions, capability evaluation, operation-to-capability mapping, blocked-operation context, shared reason translation labels, shared required-permissions grouping + - provider-owned: Microsoft Graph permission names, consent URLs, admin portal links, Intune RBAC remediation details, and provider-specific troubleshooting hints + - mixed UI bridge: provider-connections, onboarding readiness, required-permissions diagnostics, and support guidance +- **Neutral platform terms preserved or introduced**: `provider capability`, `capability key`, `capability status`, `target workflow`, `provider requirement`, `provider connection`, `managed environment`, `required capability`, and `remediation hint` +- **Provider-specific semantics retained and why**: raw Graph permission names, Intune RBAC-specific prerequisite text, consent steps, and required-permissions URLs remain necessary for the current Microsoft provider implementation and operator troubleshooting. They stay nested under provider-owned remediation or evidence sections rather than defining the shared capability vocabulary. +- **Why this does not deepen provider coupling accidentally**: the slice moves the shared operator contract away from raw Graph permission names and cluster-specific labels. Provider-specific details remain explicit nested evidence and do not define the platform-core capability keys. +- **Follow-up path**: Spec `284` for broader provider-neutral artifact taxonomy, Spec `285` for workspace-first RBAC scoping, Spec `286` for broader copy neutralization, and Spec `287` for no-legacy enforcement + +## UI / Surface Guardrail Impact + +| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note | +|---|---|---|---|---|---|---| +| Provider connections resource family | yes | Native Filament resource plus shared summary helpers | provider summary, blocked guidance, operation entry points | page, table, detail, modal, Livewire state | no | Reuses the existing resource and action family; the slice changes the summary and prerequisite contract only | +| Onboarding provider readiness and capability assist | yes | Native Filament custom wizard using existing onboarding shell | readiness summary, assist modal, supporting links | page, wizard, session, Livewire state | no | Reuses the current onboarding shell and required-permissions assist instead of creating a new workflow page | +| Required permissions diagnostic page | yes | Native Filament page with inline diagnostic matrix | diagnostic grouping, supporting links, filter state | page, table, URL-query | no | Keeps the page diagnostic-first and read-only; capability group headings and summary become the new primary explanation layer | + +## Decision-First Surface Role + +| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction | +|---|---|---|---|---|---|---|---| +| Provider connections resource family | Primary Decision Surface | Operator decides whether one provider connection is ready for the target workflow | provider, target workflow capability status, blocking reason, one next action | consent detail, raw Graph permissions, provider profile detail, and run follow-up stay secondary | Primary because this is where operators decide whether the connection is usable now | Matches the existing integrations workflow | Removes the need to reconstruct readiness from separate permission pages and blocked-operation prose | +| Onboarding provider readiness and capability assist | Primary Decision Surface | Operator decides whether onboarding can continue or whether one capability blocker must be resolved first | selected connection, missing capability, one recommended next step | detailed permission matrix, provider-specific remediation, and support detail stay secondary | Primary because onboarding cannot continue safely until the capability gap is understood | Keeps the decision inside the existing onboarding flow | Prevents operators from leaving onboarding just to decode provider prerequisites | +| Required permissions diagnostic page | Secondary Context Surface | Operator validates why a capability is missing and which exact grants or provider requirements back that result | capability group status, freshness, one next diagnostic or remediation action | raw Graph permission keys, cluster rows, and provider-specific detail stay deeper in the matrix | Secondary because it proves and explains an already identified blocker instead of owning the first workflow decision | Preserves the page as the canonical diagnostic deep dive | Prevents every other surface from duplicating the full permission matrix | + +## Audience-Aware Disclosure + +| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention | +|---|---|---|---|---|---|---|---| +| Provider connections resource family | operator-MSP, support-platform | capability label, status, blocking reason, next action, target scope | consent posture, verification freshness, provider check outcome | raw Graph permission names, portal links, provider profile identifiers | `Check connection`, `Open required permissions`, or existing primary provider action depending on status | provider-specific profile detail and raw permission lists stay nested | the detail page explains the blocker once through capability language and uses nested evidence for the rest | +| Onboarding provider readiness and capability assist | operator-MSP, support-platform | selected connection, missing capability, short explanation, continue-or-fix decision | capability freshness, blocked reason, readiness details | raw permission list, provider-specific remediation, support detail | `Open required permissions` or `Continue` depending on status | provider-specific details stay in the assist modal or downstream page | onboarding uses the same capability labels as provider-connections rather than a second vocabulary | +| Required permissions diagnostic page | operator-MSP, support-platform | capability groups, group status, freshness, top-level explanation | grouped permission rows, cluster-specific evidence, verification timing | raw provider-specific links and technical detail stay lower in the page | `Re-run verification` or `Manage provider connection` depending on state | raw permission rows stay below the capability summary, not in the header | the page introduces capability truth once, then uses the row matrix as supporting evidence | + +## UI/UX Surface Classification + +| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections resource family | List / Detail / Integrations | CRUD / List-first Resource | Open one connection, verify it, or inspect the missing capability | clickable row to View | required | grouped under `More` plus the existing primary view/header affordances | grouped under `More` and confirmation-protected where dangerous | `/admin/provider-connections` | `/admin/provider-connections/{record}` and `/admin/provider-connections/{record}/edit` | workspace context, managed environment filter, provider, target scope | Provider connection | target workflow capability and blocking state | none | +| Onboarding provider readiness and capability assist | Workflow Hub / Wizard / Readiness | workflow-step selector plus diagnostic assist | resolve the missing capability or continue onboarding | in-step selection and explicit assist action | forbidden | supporting links and secondary diagnostics stay in the assist modal or helper area | none introduced by this slice | named onboarding routes `admin.onboarding` and `admin.onboarding.draft` | same wizard step or draft route | current workspace, current managed environment, selected provider connection | Provider capability | missing capability and one next step | none | +| Required permissions diagnostic page | List / Diagnostic / Read-only | diagnostic matrix page | inspect the capability group that explains the blocker | inline grouped matrix, not a second detail page | forbidden | filter reset, copy flows, and supporting links stay inside the page body | none | `/admin/workspaces/{workspace}/environments/{managed_environment}/required-permissions` | same page | current workspace, current managed environment | Required permissions / Provider capability | capability group status and freshness | existing page-level action-surface exemptions remain valid | + +## Operator Surface Contract + +| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections resource family | Workspace operator | Decide whether this provider connection is ready for the workflow they want to run | List/detail integration resource | Is this provider connection capable of running the workflow I need? | capability status, target workflow, target scope, short reason, one next step | consent detail, raw permissions, provider profile detail, run history | capability status, verification freshness, consent posture, lifecycle | `TenantPilot only` for connection metadata changes; provider operations keep their existing mutation scope semantics | Open, Check connection, Open required permissions, existing provider actions | existing dangerous provider-connection mutations only | +| Onboarding provider readiness and capability assist | Workspace operator | Decide whether onboarding can continue or which prerequisite must be resolved first | Wizard step plus assist modal | What exact provider capability blocks activation or the selected bootstrap action? | selected connection, missing capability, short explanation, one next step | grouped permission evidence, provider-specific remediation, support detail | capability status, readiness, freshness | onboarding draft state only | Continue, Open required permissions, Create or manage connection | none introduced by this slice | +| Required permissions diagnostic page | Workspace operator | Decide which provider requirement must be fixed after a capability blocker is identified | Read-only diagnostic matrix | Which exact requirement backs the missing capability, and what should I do next? | capability group heading, status, freshness, summary counts | raw permission rows, provider-specific links, technical details | capability status, row-level permission presence, freshness | none | Re-run verification, Manage provider connection, Clear filters | none | + +## Proportionality Review + +- **New source of truth?**: no +- **New persisted entity/table/artifact?**: no +- **New abstraction?**: yes +- **New enum/state/reason family?**: yes, one bounded derived capability-status family (`supported`, `missing`, `blocked`, `unknown`, `not_applicable`) +- **New cross-domain UI framework/taxonomy?**: no +- **Current operator problem**: provider-backed workflows already depend on permission and provider-precondition truth, but that truth is fragmented across required-permissions diagnostics, onboarding readiness, provider-connection summaries, and blocked-operation messaging. +- **Existing structure is insufficient because**: current structures only describe low-level permission rows, provider connection verification state, or one blocked reason at a time. They do not give operators one stable workflow-capability answer across shared surfaces. +- **Narrowest correct implementation**: one bounded provider capability registry and one derived evaluator over the existing provider connection, permission-cluster, and operation-start seams, with no new persistence and no wider provider framework. +- **Ownership cost**: maintain capability definitions, status mapping, provider-owned remediation evidence, and test alignment across multiple consumers. That cost is contained because all consumers already exist. +- **Alternative intentionally rejected**: page-local labels, a new persisted provider-capability ledger, or a generalized multi-provider policy engine. The first option leaves current drift intact, and the latter two import complexity without a current-release need. +- **Release truth**: current-release truth + +### External implementation prerequisite + +Spec `281` must already be merged or otherwise present on the implementation branch before `283` runtime work begins. The capability registry is intentionally layered on the provider-neutral target-scope and provider-identity contract prepared there. + +### Compatibility posture + +This feature assumes a pre-production environment. + +Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. + +Canonical replacement is preferred over preservation. + +## Testing / Lane / Runtime Impact + +- **Test purpose / classification**: Unit, Feature, Browser +- **Validation lane(s)**: fast-feedback, confidence, browser +- **Why this classification and these lanes are sufficient**: the registry and evaluator logic are pure derivation and deserve unit proof. Shared blocked-operation, onboarding, provider-connection, and required-permissions behavior need feature coverage, and one browser smoke proves the real operator path from provider connection or onboarding into the capability assist without inventing a broad browser suite. +- **New or expanded test families**: one provider-capability registry unit family, one provider-capability evaluation feature family, one provider-operation capability-gate feature family, one provider-connections summary feature family, one onboarding capability-assist feature family, one required-permissions capability-grouping feature family, and one narrow browser smoke +- **Fixture / helper cost impact**: moderate; proof needs workspace, managed environment, provider connection, permission snapshot or verification data, and representative blocked-operation context without widening shared fixture defaults +- **Heavy-family visibility / justification**: one browser smoke only; no heavy-governance family is justified +- **Special surface test profile**: standard-native-filament, workflow-hub, shared-detail-family +- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for provider-connections and the required-permissions page; onboarding still needs one workflow-hub smoke because the feature crosses wizard state and assist flow +- **Reviewer handoff**: reviewers must verify that capability results remain derived, that no provider-capability table or ledger appears, that `ProviderConnectionResource` stays non-globally-searchable with View and Edit pages intact, that existing destructive actions keep confirmation plus server authorization, that provider registration stays in `apps/platform/bootstrap/providers.php`, that Filament stays v5 on Livewire v4, and that provider-specific raw permission names remain secondary evidence rather than primary capability labels +- **Budget / baseline / trend impact**: contained feature-local increase only +- **Escalation needed**: none +- **Active feature PR close-out entry**: Guardrail +- **Planned validation commands**: + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderCapabilityRegistryTest.php tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderCapabilityEvaluationTest.php tests/Feature/Providers/ProviderOperationCapabilityGateTest.php tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php)` + - `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)` + +## Candidate Selection Gate Summary + +- **Selected candidate**: `283 - Provider Capability Registry v1` +- **Source locations**: + - `docs/product/spec-candidates.md` under the reserved workspace-first / managed-environment cutover pack + - `docs/product/roadmap.md` under the same pack ordering +- **Why selected now**: the active queue is manual-promotion-only, and the user explicitly requested reserved slot `283`. Repo truth confirms that the remaining unspecced gap at this slot is workflow capability truth above the already-prepared provider-connection and target-scope seams. +- **Why close alternatives were deferred**: + - `282` already exists as a separate prepared package for governance artifact retargeting and must not be overwritten + - `284` depends on a stable capability contract and belongs to broader artifact-source taxonomy work + - `285` is about user or environment access scoping, not provider application capability truth + - `286` should follow concrete capability labels rather than invent them through copy-first work + - `287` should harden the finished cutover slices instead of expanding `283` +- **Smallest viable implementation slice**: one derived provider capability registry and evaluation path reused by provider-connections, onboarding, required-permissions diagnostics, blocked-operation translation, and shared provider-operation gating +- **Documented deviations from raw candidate wording**: + - the raw candidate proposed persisted fields such as `provider_capability_key`, `status`, `reason_code`, `provider_requirement_key`, `last_checked_at`, and `metadata`; repo truth narrows this to a derived runtime contract with no new table or independent artifact + - the raw candidate listed broader future-facing example keys such as `identity_access_policy_read`, `evidence_snapshot_write`, and `review_publish`; the current-release slice narrows keys to workflows already present in repo truth + - repo truth already routes provider-start blocking through `ProviderOperationStartGate`, `ProviderReasonTranslator`, and required-permissions diagnostics, so this package standardizes the capability layer above those seams instead of pretending raw Microsoft permission constants are checked directly everywhere + +## Completed-Spec Guardrail Result + +- `specs/279-workspace-managed-environment-core/` already exists with implementation-close-out history and remains historical prerequisite context only +- `specs/280-workspace-tenancy-environment-routing/` already exists as an adjacent prepared package and remains separate +- `specs/281-provider-connection-scope/` already exists with completed implementation-task markers and remains completed or historical context only; it is not modified by this package +- `specs/282-governance-artifact-retargeting/` already exists as a separate prepared package and is not modified by this package +- the target package `specs/283-provider-capability-registry/` did not exist before this prep run and is the sole new package created here + +## Deferred Adjacent Candidates + +- `284 - Provider-neutral Artifact Source Taxonomy v1` +- `285 - Workspace-first RBAC & Environment Access Scoping` +- `286 - UI Copy, IA & Localization Neutralization` +- `287 - Cutover Quality Gates & No-Legacy Enforcement` + +## User Scenarios & Testing + +### User Story 1 - See one provider capability summary before running work (Priority: P1) + +As an operator, I want the provider-connections resource to show whether the selected connection supports the workflow I care about without forcing me to decode raw Graph permission names or provider error detail first. + +**Why this priority**: this is the first operator-facing decision seam for provider-backed work. If it stays Microsoft-permission-first, the registry does not solve the actual workflow problem. + +**Independent Test**: open one provider-connection detail page and confirm the default-visible summary shows the target workflow capability, current status, and one next step while raw provider details stay secondary. + +**Acceptance Scenarios**: + +1. **Given** a provider connection lacks one or more required prerequisites for `inventory.sync`, **When** the operator opens the connection detail page, **Then** the page shows `Inventory read` or the equivalent workflow capability as missing and offers the correct next step without leading with raw Graph permission names. +2. **Given** a provider connection satisfies the requirements for `provider.connection.check`, **When** the operator opens the connection detail page, **Then** the page shows the capability as supported and does not invent a second identity or capability vocabulary. + +--- + +### User Story 2 - Block or start provider operations with the same capability contract (Priority: P1) + +As an operator, I want provider-backed operations to block or start using the same capability keys shown on provider-connections and onboarding so the run outcome and the UI explain the same prerequisite truth. + +**Why this priority**: `ProviderOperationStartGate` is the shared execution seam. If it stays on raw permission or reason-only language, later UI consistency will still drift. + +**Independent Test**: attempt one blocked provider-backed operation and one allowed provider-backed operation, then confirm both the start outcome and any created run context use the capability key or capability summary rather than raw provider permission terms as primary truth. + +**Acceptance Scenarios**: + +1. **Given** the current managed environment is missing the capability needed for `directory.groups.sync`, **When** the operator triggers that action, **Then** the blocked result identifies the missing directory-groups capability and links to the required-permissions diagnostic path. +2. **Given** the current managed environment satisfies the capability for `provider.connection.check`, **When** the operator triggers the action, **Then** the shared start path records the capability context and starts the run without inventing a second capability explanation. + +--- + +### User Story 3 - Read the same capability truth inside onboarding and required-permissions diagnostics (Priority: P2) + +As an operator, I want onboarding verification and the required-permissions page to use the same workflow capability labels so I can move between the quick summary and the diagnostic evidence without reinterpreting the blocker. + +**Why this priority**: onboarding and the required-permissions page already exist and already talk about the same prerequisites. A capability registry only matters if those two surfaces converge. + +**Independent Test**: open onboarding verification for a managed environment with missing prerequisites, then open the required-permissions assist and confirm both surfaces use the same capability labels and status meanings. + +**Acceptance Scenarios**: + +1. **Given** onboarding shows a missing prerequisite for a selected bootstrap action, **When** the operator opens the capability assist, **Then** the assist uses the same capability label and status shown on the required-permissions page. +2. **Given** the required-permissions page groups raw permission rows under one capability heading, **When** the operator returns to onboarding, **Then** onboarding preserves that same capability label as the primary explanation and keeps the row-level detail secondary. + +--- + +### User Story 4 - Translate provider blockers consistently in diagnostics and support surfaces (Priority: P3) + +As an operator or support user, I want provider blocker messages and contextual help to name the missing capability first and keep the raw provider requirement detail as supporting evidence so supportability stays consistent across surfaces. + +**Why this priority**: the repo already has shared product knowledge and support-diagnostic seams for provider blockers. Leaving those seams on raw permission-first language would keep one of the biggest explanation drifts alive. + +**Independent Test**: trigger or simulate a provider blocker caused by missing permissions, then inspect shared help or support-diagnostic output and confirm it uses the same capability vocabulary as the main surfaces. + +**Acceptance Scenarios**: + +1. **Given** a provider blocker maps to missing Intune RBAC or Graph permissions, **When** contextual help or support diagnostics render the explanation, **Then** they name the missing capability first and keep the required-permissions link plus raw details secondary. +2. **Given** the capability is `unknown` because the permission snapshot is stale or absent, **When** diagnostics render, **Then** the message names the capability state as unknown or stale rather than pretending the capability is definitely missing. + +### Edge Cases + +- A capability with no current provider binding or no mapped provider requirements must resolve as `not_applicable` rather than `missing`. +- A stale or absent permission snapshot must resolve as `unknown` and must not silently downgrade into a false missing-permission blocker. +- A workflow that depends on both provider permissions and connection-state preconditions must distinguish `missing` from `blocked` instead of collapsing both into one generic failure. +- A provider connection that is disabled, review-required, or consent-revoked must still evaluate capabilities deterministically and surface the connection-state blocker alongside the capability key. +- If multiple capability keys map to the same raw Graph permission row, the system must avoid duplicate top-level blocker cards while still preserving full evidence on the diagnostic page. +- Existing user RBAC capability denials must remain distinct from provider application capability failures and must not be translated through the provider-capability registry. + +## Requirements + +**Constitution alignment (required):** This slice changes provider-operation gating, provider-connection summaries, onboarding verification assists, required-permissions diagnostics, and shared provider reason translation. It does not add new Graph contract registry entries, new provider implementations, or a new long-running workflow type. + +**Constitution alignment (PROP-001 / ABSTR-001 / PROV-001 / BLOAT-001):** The new registry is justified only because the repo already has multiple real consumers and conflicting explanations for the same provider prerequisite truth. No new table, no provider framework, and no broader taxonomy work are in scope. + +**Constitution alignment (XCUT-001 / UI-FIL-001 / DECIDE-001):** The feature must reuse `ProviderConnectionResource`, `ManagedTenantOnboardingWizard`, `TenantRequiredPermissions`, and the existing provider-operation start path. It may refine summary and diagnostic language, but it must not create a new provider dashboard, a second required-permissions page, or local status styling. + +**Constitution alignment (RBAC-UX):** Workspace and managed-environment membership remain the route boundaries. Existing user capability checks remain server-authorized and distinct from provider application capability evaluation. + +**Constitution alignment (TEST-GOV-001 / OPS-UX-START-001):** Proof stays bounded to unit, feature, and one narrow browser smoke. The operation-start path must keep using the shared start gate or presenter contract, with capability evaluation added as shared prerequisite logic instead of feature-local branching. + +### Functional Requirements + +- **FR-001**: The system MUST introduce one shared provider capability registry that defines business-facing capability keys for workflows already present in repo truth. +- **FR-002**: The initial capability-key inventory MUST stay bounded to current repo workflows and diagnostics and MUST NOT speculate about future provider workflows that are not yet implemented. +- **FR-003**: The initial capability-key inventory MUST cover, at minimum, provider connection checks, inventory sync, compliance snapshot, restore execution, directory group sync, and directory role-definition sync. +- **FR-004**: Capability evaluation MUST remain derived from existing provider connection state, consent state, verification state, required-permissions evidence, and provider-specific requirement mappings; it MUST NOT introduce a provider-capability table or independent persisted artifact. +- **FR-005**: Capability evaluation MUST emit a stable capability status family with the values `supported`, `missing`, `blocked`, `unknown`, and `not_applicable`. +- **FR-006**: Capability evaluation MUST also emit a stable capability key, one provider-owned remediation-hint path, and the provider requirement keys or evidence used to justify the result. +- **FR-007**: Existing provider reason codes remain the blocking reason truth where applicable; the capability contract MUST nest or reference those reason codes rather than creating a second broad reason-code family. +- **FR-008**: `ProviderOperationRegistry` MUST map each current provider-backed operation type to one or more provider capability keys instead of leaving workflow prerequisite semantics implicit. +- **FR-009**: `ProviderOperationStartGate` MUST evaluate required provider capability keys before dispatch and MUST block or start using the shared capability result instead of relying on raw permission language as primary truth. +- **FR-010**: Any created or blocked `OperationRun` context from the touched provider-operation start path MUST include the relevant capability key or capability summary as shared context and MUST keep raw provider requirement details nested as secondary evidence only. +- **FR-011**: `ProviderConnectionResource` MUST show provider capability summaries on its list, view, and edit-adjacent detail surfaces using the shared capability contract. +- **FR-012**: `ProviderConnectionResource` MUST remain non-globally-searchable in this slice and MUST keep its existing View and Edit pages as the canonical inspect destinations. +- **FR-013**: `ManagedTenantOnboardingWizard` MUST use the shared provider capability contract to describe blocked bootstrap actions, readiness, and the required-permissions assist. +- **FR-014**: `TenantRequiredPermissions` and `TenantRequiredPermissionsViewModelBuilder` MUST group or summarize raw permission rows under the shared provider capability contract so the diagnostic page proves the same blocker described by provider-connections and onboarding. +- **FR-015**: `ProviderReasonTranslator`, `ProviderNextStepsRegistry`, and any touched contextual-help or support-diagnostic consumers MUST translate missing-permission and similar provider blockers through the shared capability vocabulary first and keep provider-specific raw requirement detail second. +- **FR-016**: Provider-specific requirement mappings such as raw Graph permission names, Intune RBAC prerequisites, and admin-consent links MUST remain provider-owned metadata or evidence, not new platform-core nouns. +- **FR-017**: The feature MUST NOT introduce a dedicated provider-profile table, a provider-capability ledger, a broader provider-neutral artifact taxonomy, a user RBAC rewrite, or a routing cutover. +- **FR-018**: The feature MUST keep shared capability evaluation reusable by provider-connections, onboarding, required-permissions diagnostics, provider-operation starts, and shared reason translation rather than duplicating mapping logic locally. + +### Authorization and Safety Requirements + +- **AR-001**: Workspace membership MUST remain the first access boundary for provider-connections, onboarding, and required-permissions diagnostics. +- **AR-002**: Managed-environment entitlement MUST remain the second access boundary for those surfaces and any provider-operation start attempts made from them. +- **AR-003**: Non-members or cross-workspace or cross-environment access attempts MUST resolve as `404`, while in-scope actors missing user capabilities still resolve as `403`. +- **AR-004**: Existing destructive provider-connection actions such as setting default, enabling dedicated override, rotating or deleting dedicated credentials, reverting to platform, and enabling or disabling the connection MUST remain server-authorized and use confirmation where the current action contract already requires it. +- **AR-005**: Navigation-only provider actions such as admin-consent links or required-permissions links MUST remain clearly navigation-only and capability-gated without being misrepresented as mutations. +- **AR-006**: Provider capability evaluation MUST NOT bypass the current user RBAC gates for provider operations or configuration changes. + +### Non-Functional Requirements + +- **NFR-001**: Filament remains v5 on Livewire v4. +- **NFR-002**: Provider registration remains in `apps/platform/bootstrap/providers.php`; nothing moves to `bootstrap/app.php`. +- **NFR-003**: Asset strategy remains unchanged. No new panel or shared asset registration is expected, and deployment continues to use `cd apps/platform && php artisan filament:assets` only if some later implementation introduces registered assets. +- **NFR-004**: `ProviderConnectionResource` remains non-globally-searchable, and any touched searchable consumer such as `TenantResource` must keep its valid view destination intact. +- **NFR-005**: The feature must remain reviewable as one bounded provider-capability slice and MUST NOT silently absorb work reserved for Specs `284` through `287`. +- **NFR-006**: Default-visible capability summaries on touched operator-facing surfaces must stay Filament-native and avoid page-local badge, card, button, or color systems. + +## UI Action Matrix + +| Surface | Location | Header Actions | Inspect Affordance (List or Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create or Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Provider connections list | `ProviderConnectionResource` -> `ListProviderConnections` | preserve existing create action | clickable row to View | preserve current `More` group plus existing safe shortcuts | none | preserve current empty-state CTA | `N/A` | `N/A` | preserve current mutation audit behavior | `283` changes summary and prerequisite semantics only | +| Provider connection view and edit surfaces | `ProviderConnectionResource` -> `ViewProviderConnection`, `EditProviderConnection`, `CreateProviderConnection` | preserve `Grant admin consent` and existing `More` grouping | `N/A` | none beyond existing shared group | none | `N/A` | preserve existing header actions | preserve native save or cancel flow | preserve current mutation audit behavior | navigation-only and destructive-action discipline remain unchanged | +| Onboarding provider readiness and capability assist | `ManagedTenantOnboardingWizard` | none beyond current wizard affordances | explicit in-step selection and assist action only | none | none | preserve current create-or-select affordances | `N/A` | preserve wizard continue or back flow | preserve current onboarding audit behavior | the capability assist remains host-owned | +| Required permissions diagnostic page | `TenantRequiredPermissions` | current body-owned filter and reset actions only | inline matrix, no separate inspect destination | none | none | current clear-filters empty-state action | `N/A` | `N/A` | no new audit surface | existing action-surface exemptions remain valid | + +All other touched shared help, support, or translation consumers must keep their existing action contracts and only adopt the shared capability vocabulary. + +### Key Entities + +- **Provider Capability Definition**: one shared registry record or definition that names the business-facing capability key, label, related workflows, and provider-owned requirement mapping for a current-release provider-backed workflow. +- **Provider Capability Result**: the derived result for one managed environment and provider connection, including the capability key, status, blocking reason code when present, provider requirement keys, last-checked or freshness evidence, and one remediation hint. +- **Provider Requirement Evidence**: provider-owned raw evidence such as Graph permission names, Intune RBAC prerequisites, consent posture, and required-permissions URLs that justify the capability result without becoming the primary operator vocabulary. +- **Capability-Aware Operation Gate**: the existing provider-operation start seam extended to require capability evaluation before dispatch or blocked outcome creation. +- **Capability Grouped Diagnostic View**: the required-permissions view model that groups raw permission evidence under capability headings and statuses. + +## Success Criteria + +### Measurable Outcomes + +- **SC-001**: 100% of affected default-visible provider workflow blockers on provider-connections, onboarding, and required-permissions surfaces use the shared capability vocabulary before showing provider-specific raw requirement detail. +- **SC-002**: An operator can move from a blocked provider workflow to the required-permissions diagnostic path and see the same capability label and status in 3 interactions or fewer. +- **SC-003**: 100% of affected provider-operation blocked outcomes include the shared capability key or capability summary in context instead of relying on raw Graph permission names as the primary explanation. +- **SC-004**: The implementation introduces no provider-capability table or other new persisted provider ledger. \ No newline at end of file diff --git a/specs/283-provider-capability-registry/tasks.md b/specs/283-provider-capability-registry/tasks.md new file mode 100644 index 00000000..74ec9d3d --- /dev/null +++ b/specs/283-provider-capability-registry/tasks.md @@ -0,0 +1,234 @@ +--- +description: "Task list for Provider Capability Registry" +--- + +# Tasks: Provider Capability Registry + +**Input**: Design documents from `specs/283-provider-capability-registry/` +**Prerequisites**: `specs/283-provider-capability-registry/spec.md`, `specs/283-provider-capability-registry/plan.md`, `specs/283-provider-capability-registry/checklists/requirements.md`, `specs/283-provider-capability-registry/research.md`, `specs/283-provider-capability-registry/data-model.md`, `specs/283-provider-capability-registry/quickstart.md`, and `specs/283-provider-capability-registry/contracts/provider-capability-registry.logical.openapi.yaml` +**Implementation Posture**: Runtime implementation complete. Targeted unit, feature, browser smoke, and dirty-file formatting validation passed in the implementation loop. +**Tests**: REQUIRED (Pest). Keep proof bounded to `apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php`, `apps/platform/tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php`, `apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php`, `apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php`, `apps/platform/tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php`, `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php`, `apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php`, `apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php`, and `apps/platform/tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php`. +**Operations**: No new `OperationRun` family. Reuse `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, the existing `ProviderOperationStartResultPresenter` path, `OperationUxPresenter`, and the current `OperationRunService` lifecycle ownership. This slice only adds a shared provider capability prerequisite contract over that existing provider-operation path. +**RBAC**: Workspace membership remains the first `404` boundary, managed-environment access remains the second `404` boundary, and current user capability denials such as `PROVIDER_VIEW`, `PROVIDER_MANAGE`, `PROVIDER_MANAGE_DEDICATED`, `PROVIDER_RUN`, `TENANT_MANAGE`, and `TENANT_SYNC` remain `403`. Provider application capability failures remain a distinct derived result and must not be folded into user RBAC. +**Shared Pattern Reuse**: Reuse `ProviderOperationRegistry`, `ProviderOperationStartGate`, `ProviderReasonTranslator`, `ProviderNextStepsRegistry`, `TenantPermissionCheckClusters`, `TenantRequiredPermissionsViewModelBuilder`, `ProviderConnectionSurfaceSummary`, `ProviderConnectionResource`, `TenantRequiredPermissions`, `ManagedTenantOnboardingWizard`, `RequiredPermissionsLinks`, and the current contextual-help or support-diagnostic seams. Do not introduce a provider-capability table, a provider framework, a broader taxonomy, a user RBAC rewrite, route cutover work, copy-neutralization work, or adjacent Spec `284` through `287` scope. +**Filament / Panel Guardrails**: Filament remains v5 on Livewire v4. Provider registration remains in `apps/platform/bootstrap/providers.php`. `ProviderConnectionResource` remains non-globally-searchable while keeping `View` and `Edit` pages. Any touched destructive action must continue to use `->action(...)`, `->requiresConfirmation()`, and current server authorization. Asset strategy stays unchanged. +**Compatibility Posture**: Reject provider-capability persistence, future-facing capability inventories, shared raw Graph permission names as primary vocabulary, user RBAC or route-shell rewrites, and keep Specs `284` through `287` deferred. +**External Prerequisite**: Spec `281` provider-connection-scope groundwork must already be merged or otherwise present on the implementation branch before any runtime or test task starts. +**Organization**: Tasks are grouped by user story so provider-connection summary truth, provider-operation capability gating, onboarding and required-permissions convergence, and support-diagnostic consistency remain independently testable. +**Review Outcome**: `implementation-ready` +**Workflow Outcome**: `keep` +**Test-governance Outcome**: `keep` + +## Test Governance Checklist + +- [x] Lane assignment stays `fast-feedback`, `confidence`, and one narrow `browser` lane. +- [x] New or changed tests stay in the named unit, feature, and browser files only. +- [x] Workspace, managed-environment, provider-connection, and permission-evidence fixtures remain explicit and opt-in; no hidden shared defaults or provider-capability persistence is planned. +- [x] Planned validation commands match `spec.md`, `plan.md`, and `quickstart.md` exactly. +- [x] `standard-native-filament`, `workflow-hub`, and shared provider-operation expectations stay explicit for touched surfaces. +- [x] Any attempt to absorb Specs `284` through `287` resolves as `split` or `reject-or-split`, not hidden follow-up inside `283`. + +## Canonical Initial Capability Inventory + +- `provider_connection_check` -> `provider.connection.check` +- `inventory_read` -> `inventory.sync` +- `configuration_read` -> `compliance.snapshot` +- `restore_execute` -> `restore.execute` +- `directory_groups_read` -> `directory.groups.sync` +- `directory_role_definitions_read` -> `directory.role_definitions.sync` + +This bounded inventory is authoritative for Spec `283` tasks and must not widen into future-facing capability keys during implementation. + +## Phase 0: External Gate + +**Purpose**: Confirm the provider-boundary prerequisite from Spec `281` is available before implementation begins. + +- [x] T000 Confirm Spec `281` is already merged or otherwise present on the implementation branch before any runtime or test task begins. + +--- + +## Phase 1: Setup (Shared Context) + +**Purpose**: Confirm the bounded provider-capability inventory, proof files, and deferred-scope posture before runtime edits begin. + +- [x] T001 Review `specs/283-provider-capability-registry/spec.md`, `plan.md`, `checklists/requirements.md`, `research.md`, `data-model.md`, `quickstart.md`, and `contracts/provider-capability-registry.logical.openapi.yaml` together so implementation stays on Spec `283` only. +- [x] T002 [P] Confirm the current provider-connection and Filament guardrail seams in `apps/platform/app/Models/ProviderConnection.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` before changing shared summaries. +- [x] T003 [P] Confirm the current provider-operation seams in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, and any cooperating presenter or result classes that already own blocked-versus-started feedback. +- [x] T004 [P] Confirm the current required-permissions and verification seams in `apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php`, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php`, and `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` before changing diagnostic grouping. +- [x] T005 [P] Confirm the current onboarding and supporting-link seams in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, and `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` before capability summaries are introduced. +- [x] T006 [P] Confirm the current provider reason, help, and boundary seams in `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Providers/ProviderNextStepsRegistry.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php`, `apps/platform/config/provider_boundaries.php`, and `specs/283-provider-capability-registry/checklists/requirements.md` so Specs `284` through `287` remain explicitly out of scope. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the proving suite and the canonical shared capability contract that every story depends on. + +**Critical**: No user-story work should begin until this phase is complete. + +- [x] T007 [P] Add failing coverage in `apps/platform/tests/Unit/Providers/ProviderCapabilityRegistryTest.php` for the initial capability inventory, operator-facing labels, provider-owned requirement mappings, and the bounded current-release key set. +- [x] T008 [P] Add failing coverage in `apps/platform/tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php` for mapping current permission-cluster evidence into the shared capability contract without promoting raw Graph permission names into the primary vocabulary. +- [x] T009 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php` for `supported`, `missing`, `blocked`, `unknown`, and `not_applicable` capability results derived from existing provider connection and permission evidence. +- [x] T010 [P] Add failing coverage in `apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php` for provider-backed operation types resolving one or more capability keys before the shared start gate allows or blocks the request. +- [x] T011 [P] Add the narrow browser smoke in `apps/platform/tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php` for one provider-connection or onboarding flow into required-permissions diagnostics under the live Filament shell. +- [x] T012 Introduce the bounded shared capability contract in the smallest viable support seam under `apps/platform/app/Support/Providers/Capabilities/` and update `apps/platform/config/provider_boundaries.php` only as needed so later story work consumes one canonical capability definition and evaluation path without reopening Specs `284` through `287`. + +**Checkpoint**: The proving files exist, the provider-capability contract is explicit, and later stories can reuse one canonical shared capability definition and evaluation path. + +--- + +## Phase 3: User Story 1 - Inspect a provider connection with one capability-first summary (Priority: P1) + +**Goal**: Provider-connections list and detail surfaces tell the operator which workflows the current connection supports without forcing raw provider requirements to become the first explanation layer. + +**Independent Test**: Open the provider-connections list and one connection detail page for a managed environment, then confirm the default-visible summary shows the relevant workflow capability, its status, and one next step while raw provider evidence stays secondary. + +### Tests for User Story 1 + +- [x] T013 [P] [US1] Extend `apps/platform/tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php` after T012 to prove `ProviderConnectionResource` list, view, and edit-adjacent summary surfaces show the shared capability label, capability status, and next step first while keeping raw provider detail nested and preserving workspace or managed-environment `404` versus in-scope capability `403` behavior. +- [x] T014 [P] [US1] Extend `apps/platform/tests/Feature/Providers/ProviderCapabilityEvaluationTest.php` after T012 to prove the same provider connection produces the same capability result for provider-connections summary consumers and later shared consumers. + +### Implementation for User Story 1 + +- [x] T015 [US1] Update the new capability registry and evaluator seam plus `apps/platform/app/Support/Providers/TargetScope/ProviderConnectionSurfaceSummary.php` so provider-connection summary adapters can ask for shared capability results without reformatting raw evidence locally. +- [x] T016 [US1] Update `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/ViewProviderConnection.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php` so provider-connections surfaces converge on the shared capability-first summary while preserving non-global-search posture and existing confirmation-protected destructive actions. + +**Checkpoint**: Provider-connections list and detail surfaces now tell one capability-first story before any raw provider requirement detail is disclosed. + +--- + +## Phase 4: User Story 2 - Start provider work with the same capability contract (Priority: P1) + +**Goal**: Provider-backed operations start or block using the same capability keys and status semantics shown on provider-connections and later onboarding or diagnostics surfaces. + +**Independent Test**: Trigger one blocked provider-backed action and one allowed provider-backed action, then confirm the start result and any resulting run context use the shared capability contract instead of raw provider requirement names as the primary explanation. + +### Tests for User Story 2 + +- [x] T017 [P] [US2] Extend `apps/platform/tests/Feature/Providers/ProviderOperationCapabilityGateTest.php` after T012 to prove blocked and allowed provider-backed operations evaluate shared capability keys before dispatch, carry the resulting capability context through the shared start path, and keep existing user RBAC denials distinct from provider capability blockers. +- [x] T018 [P] [US2] Extend `apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php` after T012 to prove missing-permission or blocked-operation translations adopt the same capability vocabulary as the start-gate result. + +### Implementation for User Story 2 + +- [x] T019 [US2] Update `apps/platform/app/Services/Providers/ProviderOperationRegistry.php` so current provider-backed operation types map to explicit provider capability keys in addition to the existing user RBAC capability requirements. +- [x] T020 [US2] Update `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` and any cooperating provider-operation result presenter seams so the shared start path evaluates required provider capability keys, blocks or starts accordingly, and records capability context as shared run metadata. +- [x] T021 [US2] Update `apps/platform/app/Support/Providers/ProviderReasonTranslator.php` and `apps/platform/app/Support/Providers/ProviderNextStepsRegistry.php` only where required so blocked-operation guidance names the missing capability first and routes operators to the existing required-permissions path. + +**Checkpoint**: Provider-operation start and blocked flows now carry one capability-first explanation that matches the provider-connections summary contract. + +--- + +## Phase 5: User Story 3 - See the same capability truth in onboarding and required permissions (Priority: P2) + +**Goal**: Onboarding readiness and the required-permissions page use the same capability labels and status meanings so operators can move from summary to diagnostic evidence without reinterpreting the blocker. + +**Independent Test**: Open onboarding verification for a managed environment with missing provider prerequisites, then open the required-permissions assist and confirm both surfaces use the same capability label and status while keeping raw permission rows as supporting evidence. + +### Tests for User Story 3 + +- [x] T022 [P] [US3] Extend `apps/platform/tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php` after T012 to prove onboarding readiness and the capability assist consume the same capability label, status, and next-step contract as other shared consumers while preserving workspace or managed-environment `404` boundaries for out-of-scope actors. +- [x] T023 [P] [US3] Extend `apps/platform/tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php` after T012 to prove the required-permissions page groups or summarizes row-level evidence under the shared capability headings without hiding the raw provider-owned evidence. + +### Implementation for User Story 3 + +- [x] T024 [US3] Update `apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php` and `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php` so the required-permissions view model can expose shared capability groups plus nested permission evidence. +- [x] T025 [US3] Update `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php` so the page header or primary summary stays capability-first while the existing diagnostic matrix remains the canonical deep dive for raw evidence. +- [x] T026 [US3] Update `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` and confirm `apps/platform/app/Support/Links/RequiredPermissionsLinks.php` only changes if necessary so onboarding readiness, assist flows, and supporting links converge on the shared capability contract. + +**Checkpoint**: Onboarding and required-permissions diagnostics now describe the same provider blocker with the same capability vocabulary and evidence hierarchy. + +--- + +## Phase 6: User Story 4 - Translate provider blockers consistently in support surfaces (Priority: P3) + +**Goal**: Shared help and support-diagnostic consumers describe provider blockers with the same capability vocabulary used on operator-facing surfaces. + +**Independent Test**: Trigger or simulate one provider blocker caused by missing permissions or degraded provider state, then confirm contextual help and support-diagnostic outputs use the same capability-first explanation as the main surfaces. + +### Tests for User Story 4 + +- [x] T027 [P] [US4] Extend `apps/platform/tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php` after T012 to prove contextual-help or support-diagnostic consumers use the same capability labels and next-step guidance as provider-connections, onboarding, and operation-start flows. + +### Implementation for User Story 4 + +- [x] T028 [US4] Update `apps/platform/app/Support/ProductKnowledge/ContextualHelpCatalog.php`, `apps/platform/app/Support/ProductKnowledge/ContextualHelpResolver.php`, and any directly touched provider-diagnostic consumer seams so they use the shared capability contract rather than page-local or raw requirement vocabulary. Note: no catalog/resolver code change was required after review; `ProviderReasonTranslator` is the active diagnostic seam for provider blockers in this slice. + +**Checkpoint**: Shared help and support-diagnostic outputs now reinforce the same capability-first explanation seen elsewhere. + +--- + +## Phase 7: Polish & Cross-Cutting Validation + +**Purpose**: Run the exact bounded proof set, perform the final Filament and provider-boundary review, and confirm the slice stayed inside Spec `283`. + +- [x] T029 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderCapabilityRegistryTest.php tests/Unit/Verification/TenantPermissionCapabilityMappingTest.php)`. +- [x] T030 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Feature/Providers/ProviderCapabilityEvaluationTest.php tests/Feature/Providers/ProviderOperationCapabilityGateTest.php tests/Feature/Filament/ProviderConnectionCapabilitySummaryTest.php tests/Feature/Onboarding/ManagedTenantOnboardingCapabilityAssistTest.php tests/Feature/RequiredPermissions/RequiredPermissionsCapabilityGroupingTest.php tests/Feature/SupportDiagnostics/ProviderCapabilityReasonTranslationTest.php)`. +- [x] T031 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail artisan test --compact tests/Browser/Spec283ProviderCapabilityRegistrySmokeTest.php)`. +- [x] T032 [P] Run `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && REPO_ROOT="$(git rev-parse --show-toplevel)" && (cd "$REPO_ROOT/apps/platform" && ./vendor/bin/sail bin pint --dirty --format agent)`. +- [x] T033 [P] Review `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`, `apps/platform/app/Services/Providers/ProviderOperationStartGate.php`, `apps/platform/app/Support/Providers/ProviderReasonTranslator.php`, `apps/platform/app/Support/Verification/TenantPermissionCheckClusters.php`, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php`, `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`, `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, `apps/platform/app/Support/Links/RequiredPermissionsLinks.php`, `apps/platform/config/provider_boundaries.php`, and `apps/platform/bootstrap/providers.php` to confirm Filament v5 or Livewire v4 compliance, unchanged provider registration location, unchanged asset strategy, preserved destructive-action confirmation plus authorization, truthful non-global-search posture, derived-only capability state, and Specs `284` through `287` staying deferred. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Phase 0 (External Gate)**: no dependencies; complete before implementation starts. +- **Phase 1 (Setup)**: depends on Phase 0. +- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all story work. +- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical provider-connection capability summary contract. +- **Phase 4 (US2)**: depends on Phase 2 and should ship with or immediately after US1 so provider-operation start results match the provider-connection summary contract. +- **Phase 5 (US3)**: depends on US1 and US2 because onboarding and diagnostics should reuse the final shared capability contract rather than an intermediate one. +- **Phase 6 (US4)**: depends on US2 and US3 so support surfaces inherit the final explanation contract. +- **Phase 7 (Polish)**: depends on all desired user stories being complete. + +### User Story Dependencies + +- **US1 (P1)**: independently testable after Phase 2 and is the first required increment. +- **US2 (P1)**: independently testable after Phase 2, but should ship after or with US1 because the shared start-gate language should match the summary language. +- **US3 (P2)**: independently testable after US1 and US2 and should follow once the shared capability contract is stable. +- **US4 (P3)**: independently testable after US2 and US3 and closes the remaining explanation drift in support surfaces. + +### Within Each User Story + +- Write or extend the listed Pest coverage first and make it fail for the intended gap. +- Apply the smallest shared-seam changes needed to satisfy the story without reopening Specs `284` through `287`. +- Re-run the narrowest relevant validation command for that story before moving to the next story. + +## Parallel Execution Examples + +- **Setup**: T002 through T006 can run in parallel once T000 and T001 set the bounded scope. +- **Foundational**: T007 through T011 can run in parallel before T012 converges the canonical capability contract. +- **US1**: T013 and T014 can run in parallel; T015 and T016 should merge serially around the shared summary and Filament resource files. +- **US2**: T017 and T018 can run in parallel; T019 through T021 should follow serially around the shared provider-operation seams. +- **US3**: T022 and T023 can run in parallel; T024 through T026 should merge serially around the diagnostic and onboarding surfaces. +- **US4**: T027 can run alongside related implementation prep, then T028 finalizes shared help and support alignment. +- **Polish**: T029 through T032 can run in parallel after implementation is complete; T033 should close the bounded-scope review last. + +## Implementation Strategy + +### Suggested MVP Scope + +- MVP = **US1 + US2**. Land the shared capability contract first on provider-connections and provider-operation starts so the most visible workflow decisions stop depending on raw provider requirement language. + +### Incremental Delivery + +1. Complete Phase 0, Phase 1, and Phase 2. +2. Deliver US1 so provider-connections exposes one capability-first summary contract. +3. Deliver US2 so blocked and allowed provider-backed workflows use the same capability contract. +4. Deliver US3 so onboarding and required-permissions diagnostics inherit the same summary and evidence hierarchy. +5. Deliver US4 so support and contextual-help surfaces stop drifting from the operator-facing explanation. +6. Finish with the exact validation commands and final bounded-scope review in Phase 7. + +### Team Strategy + +1. Parallelize the failing test work first. +2. Serialize merges around `apps/platform/app/Support/Providers/Capabilities/`, `apps/platform/app/Services/Providers/`, `apps/platform/app/Services/Intune/TenantRequiredPermissionsViewModelBuilder.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` to avoid conflicting contract-shape edits. +3. Reject any implementation branch that introduces persistence, future-facing capability keys, provider frameworks, RBAC redesign, route-shell rewrites, or adjacent-spec cutover work. + +## Deferred Follow-Ups / Non-Goals + +- Spec `284` provider-neutral artifact source or taxonomy work +- Spec `285` workspace-first RBAC and environment-access redesign +- Spec `286` broader UI copy, IA, and localization neutralization +- Spec `287` cutover quality gates and no-legacy enforcement beyond this bounded capability slice